新增答辩用后台管理系统

This commit is contained in:
2026-06-25 18:40:23 +08:00
parent 581cc995c6
commit 0140bb0cc8
98 changed files with 16021 additions and 420 deletions
@@ -194,4 +194,47 @@ public class CheckInHandler {
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
/**
* 管理员查询所有签到记录(支持排序)
*
* GET /api/checkIn/admin/records
*/
public Mono<ServerResponse> getAllSignInRecords(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
String sortBy = request.queryParam("sortBy").orElse("signInTime");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询所有签到记录, startDate: {}, endDate: {}, sortBy: {}, sortOrder: {}", startDate, endDate, sortBy, sortOrder);
return checkService.getAllSignInRecords(startDate, endDate, sortBy, sortOrder)
.collectList()
.flatMap(records -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", records)));
}
/**
* 管理员查询签到统计(不限会员)
*
* GET /api/checkIn/admin/statistics
*/
public Mono<ServerResponse> getAllSignInStatistics(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询签到统计, startDate: {}, endDate: {}", startDate, endDate);
return checkService.getAllSignInStats(startDate, endDate)
.flatMap(stats -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
}
@@ -57,6 +57,12 @@ public interface SignInRecordRepository extends R2dbcRepository<SignInRecord, Lo
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 根据时间范围查询签到记录(支持动态排序)
*/
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRangeSorted(LocalDateTime startTime, LocalDateTime endTime);
/**
* 统计会员在时间范围内的签到次数
*/
@@ -78,4 +78,20 @@ public interface ICheckInService {
* @return 签到统计VO
*/
Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
/**
* 管理员查询所有签到记录(支持排序)
*
* @param startTime 开始时间
* @param endTime 结束时间
* @param sortBy 排序字段
* @param sortOrder 排序方向
* @return 签到记录列表(含会员姓名、卡类型)
*/
Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder);
/**
* 管理员查询签到统计(不限会员)
*/
Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime);
}
@@ -16,11 +16,13 @@ import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil;
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.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -47,6 +49,7 @@ public class CheckServiceImpl implements ICheckInService {
private final MemberCardRepository memberCardRepository;
private final SignInRecordRepository signInRecordRepository;
private final IGroupCourseBookingService groupCourseBookingService;
private final IMemberRepository memberRepository;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -430,6 +433,83 @@ public class CheckServiceImpl implements ICheckInService {
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() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
@@ -59,6 +59,16 @@ public class SignInRecordVO {
*/
private String source;
/**
* 会员姓名(关联查询)
*/
private String memberName;
/**
* 会员卡类型名称(关联查询)
*/
private String memberCardType;
/**
* 创建时间
*/
@@ -26,7 +26,7 @@ public class DataStatisticsDao {
* 统计指定时间范围内新增会员数
*/
public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND is_deleted = false")
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND deleted_at IS NULL")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
@@ -37,7 +37,7 @@ public class DataStatisticsDao {
* 统计总会员数
*/
public Mono<Long> countTotalMembers() {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE is_deleted = false")
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE deleted_at IS NULL")
.map(row -> row.get(0, Long.class))
.one();
}
@@ -4,6 +4,8 @@ import cn.novalon.gym.manage.datacount.domain.*;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -25,6 +27,8 @@ import java.time.format.DateTimeFormatter;
@Tag(name = "数据统计", description = "数据统计相关操作")
public class DataStatisticsHandler {
private static final Logger log = LoggerFactory.getLogger(DataStatisticsHandler.class);
@Autowired
private IDataStatisticsService dataStatisticsService;
@@ -35,6 +39,7 @@ public class DataStatisticsHandler {
return dataStatisticsService.getStatisticsSummaryWithCache(query)
.flatMap(summary -> ServerResponse.ok().bodyValue(summary))
.onErrorResume(e -> {
log.error("获取综合统计数据失败", e);
StatisticsSummary errorSummary = StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
@@ -13,6 +13,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.io.ByteArrayOutputStream;
@@ -22,7 +23,9 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
@@ -173,9 +176,25 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
@Override
public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) {
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
String statDate = query.getStartTime() != null
? query.getStartTime().toLocalDate().toString()
: LocalDateTime.now().toLocalDate().toString();
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query)
.onErrorResume(e -> {
log.error("获取会员统计数据失败", e);
return Mono.just(MemberStatistics.builder().statDate(statDate).build());
});
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query)
.onErrorResume(e -> {
log.error("获取预约统计数据失败", e);
return Mono.just(BookingStatistics.builder().statDate(statDate).build());
});
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query)
.onErrorResume(e -> {
log.error("获取签到统计数据失败", e);
return Mono.just(SignInStatistics.builder().statDate(statDate).build());
});
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
.map(tuple -> StatisticsSummary.builder()
@@ -188,21 +207,75 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
}
@Override
public reactor.core.publisher.Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) {
public Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) {
// 历史统计数据查询(从Redis缓存中获取)
String cacheKey = buildCacheKey(query);
return redisUtil.get(cacheKey, String.class)
.flatMapMany(json -> {
try {
java.util.List<DataStatistics> stats = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructCollectionType(java.util.List.class, DataStatistics.class));
return reactor.core.publisher.Flux.fromIterable(stats);
List<DataStatistics> stats = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructCollectionType(List.class, DataStatistics.class));
return Flux.fromIterable(stats);
} catch (Exception e) {
log.error("Failed to parse historical statistics from cache", e);
return reactor.core.publisher.Flux.empty();
return Flux.empty();
}
})
.switchIfEmpty(reactor.core.publisher.Flux.empty());
.switchIfEmpty(buildLiveStatistics(query));
}
/**
* 缓存未命中时,从数据库实时构建统计数据
*/
private Flux<DataStatistics> buildLiveStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
String periodType = query.getPeriodType() != null ? query.getPeriodType() : "DAY";
LocalDateTime statDate = endTime;
Mono<Long> memberCountMono = dataStatisticsDao.countNewMembers(startTime, endTime);
Mono<Long> totalMembersMono = dataStatisticsDao.countTotalMembers();
Mono<Long> bookingCountMono = dataStatisticsDao.countBookings(startTime, endTime);
Mono<Long> signInCountMono = dataStatisticsDao.countSignIns(startTime, endTime);
return Mono.zip(memberCountMono, totalMembersMono, bookingCountMono, signInCountMono)
.flatMapMany(tuple -> {
long newMembers = tuple.getT1() != null ? tuple.getT1() : 0L;
long totalMembers = tuple.getT2() != null ? tuple.getT2() : 0L;
long bookings = tuple.getT3() != null ? tuple.getT3() : 0L;
long signIns = tuple.getT4() != null ? tuple.getT4() : 0L;
List<DataStatistics> list = new ArrayList<>();
DataStatistics memberStat = DataStatistics.builder()
.statType(DataStatistics.StatType.MEMBER)
.periodType(periodType)
.statDate(statDate)
.count(totalMembers)
.extraData("新增" + newMembers + "")
.build();
list.add(memberStat);
DataStatistics bookingStat = DataStatistics.builder()
.statType(DataStatistics.StatType.BOOKING)
.periodType(periodType)
.statDate(statDate)
.count(bookings)
.extraData("新增" + bookings + "条预约")
.build();
list.add(bookingStat);
DataStatistics signInStat = DataStatistics.builder()
.statType(DataStatistics.StatType.SIGN_IN)
.periodType(periodType)
.statDate(statDate)
.count(signIns)
.extraData("新增" + signIns + "次签到")
.build();
list.add(signInStat);
return Flux.fromIterable(list);
});
}
@Override
@@ -40,6 +40,10 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
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
@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);
@@ -29,4 +29,8 @@ public interface GroupCourseTypeDao extends R2dbcRepository<GroupCourseTypeEntit
@Modifying
@Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course_type SET type_name = :typeName, base_difficulty = :baseDifficulty, description = :description, category = :category, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateFields(Long id, String typeName, Integer baseDifficulty, String description, String category, LocalDateTime updatedAt);
}
@@ -0,0 +1,120 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 通用文件上传处理器
*/
@Slf4j
@Component
@Tag(name = "文件上传", description = "通用文件上传到阿里云OSS")
public class CommonUploadHandler {
@Operation(summary = "上传图片文件", description = "上传图片到阿里云OSS,返回ossKey和预签名URL")
public Mono<ServerResponse> uploadImage(ServerRequest request) {
return request.multipartData()
.flatMap(multiValueMap -> {
List<Part> fileParts = multiValueMap.get("file");
if (fileParts == null || fileParts.isEmpty()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "未选择文件"));
}
Part part = fileParts.get(0);
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "文件格式不正确"));
}
String originalFilename = filePart.filename();
String ext = "";
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0) {
ext = originalFilename.substring(dotIndex);
}
String newFileName = UUID.randomUUID().toString().replace("-", "") + ext;
Path tempFile = null;
try {
tempFile = Files.createTempFile("upload-", newFileName);
} catch (Exception e) {
return Mono.error(new RuntimeException("创建临时文件失败", e));
}
Path finalTempFile = tempFile;
return filePart.transferTo(finalTempFile)
.then(Mono.defer(() -> {
try {
String ossKey;
try (InputStream inputStream = Files.newInputStream(finalTempFile)) {
ossKey = OSSUtil.uploadCoverToOSS(inputStream, newFileName);
}
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"message", "上传成功",
"data", Map.of(
"ossKey", ossKey,
"presignedUrl", presignedUrl,
"fileName", originalFilename
)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "上传失败: " + e.getMessage()));
} finally {
try {
Files.deleteIfExists(finalTempFile);
} catch (Exception ignored) {
}
}
}));
});
}
@Operation(summary = "获取OSS预签名URL", description = "根据ossKey生成临时访问URL,有效期5分钟")
public Mono<ServerResponse> presignUrl(ServerRequest request) {
String ossKey = request.queryParam("key").orElse(null);
if (ossKey == null || ossKey.isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "参数 key 不能为空"));
}
try {
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"data", Map.of("presignedUrl", presignedUrl)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "生成预签名URL失败: " + e.getMessage()));
}
}
}
@@ -146,18 +146,25 @@ public class CourseLabelHandler {
return request.bodyToMono(Map.class)
.flatMap(body -> {
@SuppressWarnings("unchecked")
List<Integer> labelIdsInt = (List<Integer>) body.get("labelIds");
Object labelIdsObj = body.get("labelIds");
if (labelIdsInt == null || labelIdsInt.isEmpty()) {
if (!(labelIdsObj instanceof List)) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<Long> labelIds = labelIdsInt.stream()
.map(Integer::longValue)
List<?> rawList = (List<?>) labelIdsObj;
if (rawList.isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<Long> labelIds = rawList.stream()
.map(id -> Long.valueOf(String.valueOf(id)))
.collect(java.util.stream.Collectors.toList());
return courseLabelService.addLabelsToType(typeId, labelIds)
@@ -7,10 +7,13 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
@@ -26,15 +29,21 @@ public class GroupCourseHandler {
private final Validator validator;
private final RedisUtil redisUtil;
private final ObjectMapper objectMapper;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseHandler(IGroupCourseService groupCourseService,
Validator validator,
RedisUtil redisUtil,
ObjectMapper objectMapper){
ObjectMapper objectMapper,
ISysUserService sysUserService,
AuthUtil authUtil){
this.groupCourseService = groupCourseService;
this.validator = validator;
this.redisUtil = redisUtil;
this.objectMapper = objectMapper;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表")
@@ -114,25 +123,43 @@ public class GroupCourseHandler {
});
}
@Operation(summary = "更新团课", description = "更新指定团课信息")
@Operation(summary = "更新团课", description = "更新指定团课信息,需验证管理员密码")
public Mono<ServerResponse> updateGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
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);
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(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);
});
});
});
}
@@ -197,22 +224,78 @@ public class GroupCourseHandler {
});
}
@Operation(summary = "删除团课", description = "删除指定团课(软删除)")
@Operation(summary = "删除团课", description = "删除指定团课(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
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);
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.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);
});
});
}
@@ -2,8 +2,11 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.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.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;
@@ -17,9 +20,15 @@ import java.util.Map;
public class GroupCourseRecommendHandler {
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.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
@@ -80,20 +89,73 @@ public class GroupCourseRecommendHandler {
});
}
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息")
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息,需验证管理员密码")
public Mono<ServerResponse> updateRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> {
return recommendService.update(id, recommend)
.flatMap(r -> {
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(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<>();
response.put("success", true);
response.put("message", "团课推荐更新成功");
response.put("data", r);
response.put("message", "团课推荐删除成功");
return ServerResponse.ok().bodyValue(response);
})
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
@@ -103,25 +165,6 @@ public class GroupCourseRecommendHandler {
});
}
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除)")
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "启用团课推荐", description = "启用指定团课推荐")
public Mono<ServerResponse> enableRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
@@ -142,23 +185,41 @@ public class GroupCourseRecommendHandler {
});
}
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐")
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐,需验证管理员密码")
public Mono<ServerResponse> disableRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
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);
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.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
}
@@ -2,8 +2,11 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
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.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;
@@ -17,9 +20,15 @@ import java.util.Map;
public class GroupCourseTypeHandler {
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.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
@@ -92,21 +101,76 @@ public class GroupCourseTypeHandler {
});
}
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息")
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息,需验证管理员密码")
public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> {
groupCourseType.setId(id);
return groupCourseTypeService.update(id, groupCourseType)
.flatMap(type -> {
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(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<>();
response.put("success", true);
response.put("message", "团课类型更新成功");
response.put("data", type);
response.put("message", "团课类型删除成功");
return ServerResponse.ok().bodyValue(response);
})
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
@@ -115,23 +179,4 @@ public class GroupCourseTypeHandler {
});
});
}
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除)")
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseTypeService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
}
}
@@ -27,6 +27,8 @@ public interface IGroupCourseRepository {
Mono<Void> deleteById(Long id);
Mono<GroupCourse> restoreById(Long id);
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
Flux<GroupCourse> findByCourseType(Long courseType);
@@ -169,6 +169,17 @@ public class GroupCourseRepository implements IGroupCourseRepository {
.then();
}
@Override
public Mono<GroupCourse> restoreById(Long id) {
return groupCourseDao.restoreCourse(id, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findByIdAndDeletedAtIsNull(id);
}
return Mono.error(new RuntimeException("团课恢复失败,可能该课程未被删除"));
});
}
@Override
public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) {
return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now())
@@ -105,21 +105,20 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
.flatMap(existing -> {
existing.markNotNew();
if (groupCourseType.getTypeName() != null) {
existing.setTypeName(groupCourseType.getTypeName());
}
if (groupCourseType.getBaseDifficulty() != null) {
existing.setBaseDifficulty(groupCourseType.getBaseDifficulty());
}
if (groupCourseType.getDescription() != null) {
existing.setDescription(groupCourseType.getDescription());
}
if (groupCourseType.getCategory() != null) {
existing.setCategory(groupCourseType.getCategory());
}
existing.setUpdatedAt(LocalDateTime.now());
return groupCourseTypeDao.save(existing);
String typeName = groupCourseType.getTypeName() != null
? groupCourseType.getTypeName() : existing.getTypeName();
Integer baseDifficulty = groupCourseType.getBaseDifficulty() != null
? groupCourseType.getBaseDifficulty() : existing.getBaseDifficulty();
String description = groupCourseType.getDescription() != null
? groupCourseType.getDescription() : existing.getDescription();
String category = groupCourseType.getCategory() != null
? groupCourseType.getCategory() : existing.getCategory();
LocalDateTime now = LocalDateTime.now();
return groupCourseTypeDao.updateFields(
groupCourseType.getId(), typeName, baseDifficulty,
description, category, now)
.then(groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId()));
})
.map(converter::toGroupCourseType);
}
@@ -26,6 +26,8 @@ public interface IGroupCourseService {
Mono<GroupCourse> signIn(Long courseId, Long memberId);
Mono<Void> delete(Long id);
Mono<GroupCourse> restore(Long id);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
}
@@ -536,14 +536,14 @@ public class GroupCourseService implements IGroupCourseService {
@Override
public Mono<Void> delete(Long id) {
// 先查询课程状态,只有已取消的课程才能删除
// 已取消或已结束的课程才能删除
return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> {
// 检查课程状态是否为已取消(状态码1)
if (course.getStatus() == null || !course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
return Mono.error(new RuntimeException("只有已取消的课程才能删除,当前状态: " +
(course.getStatus() != null ? course.getStatus() : "未知")));
Long status = course.getStatus();
if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
(status != null ? status : "未知")));
}
// 删除课程
@@ -554,6 +554,14 @@ public class GroupCourseService implements IGroupCourseService {
});
}
@Override
public Mono<GroupCourse> restore(Long id) {
return groupCourseRepository.restoreById(id)
.doOnSuccess(course -> logger.info("团课恢复成功 - id={}", id))
.flatMap(course -> clearCache().thenReturn(course))
.doOnError(error -> logger.error("团课恢复失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
@@ -1,6 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import org.slf4j.Logger;
@@ -9,18 +10,18 @@ import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.HashSet;
import java.util.Set;
@Service
public class GroupCourseTypeService implements IGroupCourseTypeService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
private final IGroupCourseTypeRepository groupCourseTypeRepository;
private final IGroupCourseRepository groupCourseRepository;
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository) {
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
IGroupCourseRepository groupCourseRepository) {
this.groupCourseTypeRepository = groupCourseTypeRepository;
this.groupCourseRepository = groupCourseRepository;
}
@Override
@@ -71,7 +72,14 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
@Override
public Mono<Void> delete(Long id) {
return groupCourseTypeRepository.deleteById(id)
return groupCourseRepository.findByCourseType(id)
.hasElements()
.flatMap(hasCourses -> {
if (hasCourses) {
return Mono.<Void>error(new RuntimeException("该类型下存在关联团课,无法删除"));
}
return groupCourseTypeRepository.deleteById(id);
})
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
}
@@ -1,14 +1,19 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.aliyun.oss.HttpMethod;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.PutObjectRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
/**
* 阿里云OSS工具类
@@ -19,8 +24,8 @@ public class OSSUtil {
// OSS配置信息
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY_ID = "LTAI5t9TFh9Vayeahz45kZjg";
private static final String ACCESS_KEY_SECRET = "zD6NlCeH5UhjBs4vnQVqn8Ksi3CaZz";
private static final String ACCESS_KEY_ID = "LTAI5t9wHCiH68Xjxg64Xx4Y";
private static final String ACCESS_KEY_SECRET = "isAfz1IFGAnV13LOIrVg19aPhY8aRq";
private static final String BUCKET_NAME = "ycc-filesaver";
// OSS访问地址前缀
@@ -28,36 +33,31 @@ public class OSSUtil {
// 文件存储目录
private static final String QRCODE_DIR = "qrcode/";
private static final String COVER_DIR = "cover/";
// 预签名URL有效期(秒)
private static final long PRESIGN_EXPIRE_SECONDS = 300;
/**
* 上传文件到阿里云OSS
* 上传文件到阿里云OSS(文件默认继承Bucket权限,不设置ACL)
*
* @param localFilePath 本地文件路径
* @param fileName 文件名(不含路径)
* @return OSS访问地址
* @return OSS object key(不含域名前缀)
*/
public static String uploadToOSS(String localFilePath, String fileName) {
OSS ossClient = null;
try {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
// 构建OSS文件路径:qrcode/2026/06/18/xxx.png
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
// 上传文件
ossClient.putObject(putObjectRequest);
// 构建访问地址
String accessUrl = OSS_URL_PREFIX + ossFilePath;
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
return accessUrl;
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
@@ -69,34 +69,25 @@ public class OSSUtil {
}
/**
* 上传文件到阿里云OSS(自定义存储路径)
* 上传文件到阿里云OSS(自定义存储路径,文件默认继承Bucket权限
*
* @param localFilePath 本地文件路径
* @param ossDirectory OSS存储目录
* @param fileName 文件名(不含路径)
* @return OSS访问地址
* @return OSS object key(不含域名前缀)
*/
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
OSS ossClient = null;
try {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
// 构建OSS文件路径
String ossFilePath = ossDirectory + fileName;
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
// 上传文件
ossClient.putObject(putObjectRequest);
// 构建访问地址
String accessUrl = OSS_URL_PREFIX + ossFilePath;
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
return accessUrl;
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
@@ -106,4 +97,78 @@ 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;
}
}
@@ -0,0 +1,59 @@
package cn.novalon.gym.manage.member.dto;
import cn.novalon.gym.manage.member.enums.GenderEnum;
import lombok.Data;
import java.time.LocalDate;
/**
* 管理员编辑会员信息DTO(含密码验证)
*
* @author system
* @date 2026-06-24
*/
@Data
public class AdminEditMemberDto {
/**
* 管理员密码(必填,用于验证身份)
*/
private String adminPassword;
/**
* 昵称
*/
private String nickname;
/**
* 性别
*/
private GenderEnum gender;
/**
* 生日
*/
private LocalDate birthday;
/**
* 地址
*/
private String address;
/**
* 头像
*/
private String avatar;
/**
* 转换为 UpdateMemberInfoDto
*/
public UpdateMemberInfoDto toUpdateMemberInfoDto() {
UpdateMemberInfoDto dto = new UpdateMemberInfoDto();
dto.setNickname(nickname);
dto.setGender(gender);
dto.setBirthday(birthday);
dto.setAddress(address);
dto.setAvatar(avatar);
return dto;
}
}
@@ -2,16 +2,21 @@ package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.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.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.Map;
/**
* 会员卡管理处理器
*
@@ -24,9 +29,13 @@ import reactor.core.publisher.Mono;
public class MemberCardHandler {
private final IMemberCardService memberCardService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public MemberCardHandler(IMemberCardService memberCardService) {
public MemberCardHandler(IMemberCardService memberCardService, ISysUserService sysUserService, AuthUtil authUtil) {
this.memberCardService = memberCardService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情")
@@ -60,27 +69,72 @@ public class MemberCardHandler {
.flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card));
}
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息,需验证管理员密码")
public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(MemberCard.class)
.flatMap(card -> {
card.setMemberCardId(id);
return memberCardService.save(card);
})
.flatMap(updated -> ServerResponse.ok().bodyValue(updated))
.switchIfEmpty(ServerResponse.notFound().build());
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.just(resp));
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.just(resp));
}
return request.bodyToMono(MemberCard.class)
.flatMap(body -> memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.flatMap(existing -> {
existing.setMemberCardName(body.getMemberCardName());
existing.setMemberCardType(body.getMemberCardType());
existing.setMemberCardPrice(body.getMemberCardPrice());
existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
existing.setMemberCardAmount(body.getMemberCardAmount());
existing.setMemberCardStatus(body.getMemberCardStatus());
return memberCardService.save(existing);
})
.flatMap(updated -> ServerResponse.ok().bodyValue(updated))
.switchIfEmpty(ServerResponse.notFound().build()));
});
}
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型,需验证管理员密码")
public Mono<ServerResponse> deleteMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
return memberCardService.logicalDelete(id)
.flatMap(rows -> {
if (rows > 0) {
return ServerResponse.noContent().build();
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.just(resp));
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.just(resp));
}
return ServerResponse.notFound().build();
return memberCardService.logicalDelete(id)
.flatMap(rows -> {
if (rows > 0) {
return ServerResponse.noContent().build();
}
return ServerResponse.notFound().build();
});
});
}
@@ -1,7 +1,7 @@
package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.common.exception.NotFoundException;
import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.member.dto.AdminEditMemberDto;
import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto;
import cn.novalon.gym.manage.member.dto.SearchMemberDto;
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.util.AesUtil;
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.security.JwtTokenProvider;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@@ -24,6 +24,8 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.Map;
/**
* 会员信息处理器
*
@@ -41,6 +43,7 @@ public class MemberHandler {
private final WechatAuthService wechatAuthService;
private final WechatOfficialService wechatOfficialService;
private final AuthUtil authUtil;
private final ISysUserService sysUserService;
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
@@ -165,7 +168,7 @@ public class MemberHandler {
});
}
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息")
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息,需验证管理员密码")
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
@@ -174,14 +177,30 @@ public class MemberHandler {
long memberId = NumberUtils.toLong(memberIdStr, 0L);
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
// TODO: 补充签到记录
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
return request.bodyToMono(UpdateMemberInfoDto.class)
.flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto))
.flatMap(detail -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(detail));
return request.bodyToMono(AdminEditMemberDto.class)
.flatMap(dto -> {
if (dto.getAdminPassword() == null || dto.getAdminPassword().isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return sysUserService.verifyPassword(adminId, dto.getAdminPassword())
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return memberService.adminUpdateMemberInfo(memberId, dto.toUpdateMemberInfoDto())
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result));
});
});
}
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
@@ -242,45 +242,58 @@ public class MemberServiceImpl implements MemberService {
log.debug("从缓存获取会员详情, memberId: {}", memberId);
return Mono.just(cached);
}
return memberRepository.findById(memberId)
.zipWith(
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
.collectList(),
(baseInfo, cardList) -> {
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
// 缓存反序列化异常,查数据库
return queryMemberDetailFromDb(memberId, cacheKey);
})
.switchIfEmpty(Mono.defer(() -> {
// 缓存不存在,查数据库
return queryMemberDetailFromDb(memberId, cacheKey);
}));
}
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
memberDetailVO.setGenderDesc(genderEnum.getDesc());
private Mono<MemberDetailVO> queryMemberDetailFromDb(Long memberId, String cacheKey) {
return memberRepository.findById(memberId)
.zipWith(
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
.collectList(),
(baseInfo, cardList) -> {
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
List<MemberCardInfoVO> enrichedCards = cardList.stream()
.peek(vo -> {
if (vo.getMemberCardType() != null) {
try {
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
vo.setMemberCardTypeDesc(cardType.getDesc());
} catch (IllegalArgumentException e) {
vo.setMemberCardTypeDesc(vo.getMemberCardType());
}
}
if (vo.getMemberCardStatus() != null) {
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
}
})
.collect(Collectors.toList());
memberDetailVO.setMemberCards(enrichedCards);
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
memberDetailVO.setGenderDesc(genderEnum.getDesc());
long activeCount = enrichedCards.stream()
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
.count();
memberDetailVO.setActiveCardCount((int) activeCount);
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
List<MemberCardInfoVO> enrichedCards = cardList.stream()
.peek(vo -> {
if (vo.getMemberCardType() != null) {
try {
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
vo.setMemberCardTypeDesc(cardType.getDesc());
} catch (IllegalArgumentException e) {
vo.setMemberCardTypeDesc(vo.getMemberCardType());
}
}
if (vo.getMemberCardStatus() != null) {
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
}
})
.collect(Collectors.toList());
memberDetailVO.setMemberCards(enrichedCards);
return memberDetailVO;
}
)
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo)));
});
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, "会员不存在");
}));
}
@@ -10,6 +10,7 @@ import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
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.MemberCardRecordHandler;
import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler;
@@ -80,7 +81,8 @@ public class SystemRouter {
CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler) {
PaymentHandler paymentHandler,
CommonUploadHandler commonUploadHandler) {
return route()
// ========== 诊断路由 ==========
@@ -168,6 +170,7 @@ public class SystemRouter {
.POST("/api/auth/login", authHandler::login)
.POST("/api/auth/register", authHandler::register)
.POST("/api/auth/logout", authHandler::logout)
.GET("/api/auth/me", authHandler::me)
// ========== 统计路由 ==========
.GET("/api/stats/overview", statsHandler::getOverview)
@@ -215,6 +218,10 @@ public class SystemRouter {
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
.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/{id}", permissionHandler::getPermissionById)
@@ -255,8 +262,11 @@ public class SystemRouter {
// ===== 会员卡类型管理 =====
.GET("/api/member-cards/active", memberCardHandler::getActiveCards)
.GET("/api/member-cards", memberCardHandler::listMemberCards)
.GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
.POST("/api/member-cards", memberCardHandler::createMemberCard)
.PUT("/api/member-cards/{id}", memberCardHandler::updateMemberCard)
.DELETE("/api/member-cards/{id}", memberCardHandler::deleteMemberCard)
// ===== 会员卡记录管理(核心业务)=====
.POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
@@ -331,6 +341,7 @@ public class SystemRouter {
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.POST("/api/groupCourse/{id}/restore", groupCourseHandler::restoreGroupCourse)
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
@@ -340,16 +351,18 @@ public class SystemRouter {
.GET("/api/checkIn/qrcode", checkInHandler::getQRCode)
// ===== 签到记录管理 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
.GET("/api/checkIn/records", checkInHandler::getSignInRecords)
.GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById)
// ===== 签到统计 =====
.GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics)
.GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats)
// ===== 签到数据导出 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
// ===== 管理员签到记录(全员) =====
.GET("/api/checkIn/admin/records", checkInHandler::getAllSignInRecords)
.GET("/api/checkIn/admin/statistics", checkInHandler::getAllSignInStatistics)
// ========================================
// ========== 数据统计模块路由 ============
// ========================================
+4
View File
@@ -60,6 +60,10 @@
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>
<build>
@@ -1,5 +1,8 @@
package cn.novalon.gym.manage.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
@@ -24,13 +27,23 @@ public class RedisConfig {
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
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);
// 配置序列化上下文
RedisSerializationContext<String, Object> serializationContext =
RedisSerializationContext.<String, Object>newSerializationContext()
.key(StringRedisSerializer.UTF_8)
.value(new GenericJackson2JsonRedisSerializer())
.value(serializer)
.hashKey(StringRedisSerializer.UTF_8)
.hashValue(new GenericJackson2JsonRedisSerializer())
.hashValue(serializer)
.build();
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
@@ -9,24 +9,17 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
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.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
@@ -37,19 +30,76 @@ public class OperationLogWebFilter implements WebFilter {
private final IOperationLogService operationLogService;
private final ObjectMapper objectMapper;
private static final Map<String, OperationInfo> OPERATION_MAPPING = new ConcurrentHashMap<>();
/** 精确匹配的操作映射(method:path → module, operation */
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 {
OPERATION_MAPPING.put("POST:/api/roles", new OperationInfo("角色管理", "创建角色"));
OPERATION_MAPPING.put("PUT:/api/roles/", new OperationInfo("角色管理", "更新角色"));
OPERATION_MAPPING.put("DELETE:/api/roles/", new OperationInfo("角色管理", "删除角色"));
OPERATION_MAPPING.put("POST:/api/users", new OperationInfo("用户管理", "创建用户"));
OPERATION_MAPPING.put("PUT:/api/users/", new OperationInfo("用户管理", "更新用户"));
OPERATION_MAPPING.put("DELETE:/api/users/", new OperationInfo("用户管理", "删除用户"));
OPERATION_MAPPING.put("POST:/api/users/", new OperationInfo("用户管理", "用户操作"));
OPERATION_MAPPING.put("POST:/api/menus", new OperationInfo("菜单管理", "创建菜单"));
OPERATION_MAPPING.put("PUT:/api/menus/", new OperationInfo("菜单管理", "更新菜单"));
OPERATION_MAPPING.put("DELETE:/api/menus/", new OperationInfo("菜单管理", "删除菜单"));
// ===== 精确路径匹配 =====
PRECISE_MAPPING.put("POST:/api/roles", new OperationInfo("角色管理", "创建角色"));
PRECISE_MAPPING.put("POST:/api/users", new OperationInfo("用户管理", "创建用户"));
PRECISE_MAPPING.put("POST:/api/menus", new OperationInfo("菜单管理", "创建菜单"));
PRECISE_MAPPING.put("POST:/api/auth/login", new OperationInfo("认证", "用户登录"));
PRECISE_MAPPING.put("GET:/api/groupCourse/types/categories", new OperationInfo("团课类型", "查询分类"));
PRECISE_MAPPING.put("POST:/api/groupCourse/types", new OperationInfo("团课类型", "创建类型"));
PRECISE_MAPPING.put("POST:/api/groupCourse", new OperationInfo("团课管理", "创建团课"));
PRECISE_MAPPING.put("POST:/api/member", new OperationInfo("会员管理", "创建会员"));
PRECISE_MAPPING.put("POST:/api/member-cards", 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) {
@@ -61,10 +111,8 @@ public class OperationLogWebFilter implements WebFilter {
@PostConstruct
public void init() {
logger.info("=== OperationLogWebFilter 初始化 ===");
logger.info("操作日志映射配置数量: {}", OPERATION_MAPPING.size());
OPERATION_MAPPING.forEach((key, value) -> {
logger.info(" {} -> {}:{}", key, value.module, value.operation);
});
logger.info("精确匹配配置数量: {}, 前缀匹配配置数量: {}, 模块映射数量: {}",
PRECISE_MAPPING.size(), PREFIX_MAPPING.size(), MODULE_NAMES.size());
}
@Override
@@ -72,103 +120,135 @@ public class OperationLogWebFilter implements WebFilter {
ServerHttpRequest request = exchange.getRequest();
String method = request.getMethod().name();
String path = request.getPath().value();
String key = method + ":" + path;
logger.info("WebFilter 拦截请求: {} {}", method, path);
// 先尝试精确匹配
OperationInfo operationInfo = PRECISE_MAPPING.get(key);
OperationInfo operationInfo = findOperationInfo(method, path);
if (operationInfo == null) {
// 尝试前缀匹配
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) {
logger.info("未匹配到操作日志配置,跳过: {} {}", method, path);
return chain.filter(exchange);
}
logger.info("匹配到操作日志配置: {} {} -> {}:{}", method, path, operationInfo.module, operationInfo.operation);
long startTime = System.currentTimeMillis();
String ip = IpUtils.getClientIp(request);
final OperationInfo finalInfo = operationInfo;
return Mono.deferContextual(contextView -> {
return chain.filter(exchange)
.then(Mono.defer(() -> {
long duration = System.currentTimeMillis() - startTime;
logger.info("请求处理完成,准备保存操作日志: {} {}, 耗时: {}ms", method, path, duration);
return ReactiveSecurityContextHolder.getContext()
.flatMap(securityContext -> {
Object principal = securityContext.getAuthentication().getPrincipal();
String username = principal instanceof String ? (String) principal : "system";
logger.info("获取到用户名: {}", username);
return Mono.just(username);
})
.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));
});
});
return chain.filter(exchange)
.then(Mono.defer(() -> {
long duration = System.currentTimeMillis() - startTime;
return getCurrentUsername()
.flatMap(username -> saveOperationLog(username, method, path, ip, duration, "0", null, finalInfo));
}))
.onErrorResume(error -> {
long duration = System.currentTimeMillis() - startTime;
logger.error("请求处理失败: {} {}, 错误: {}", method, path, error.getMessage());
return getCurrentUsername()
.flatMap(username -> saveOperationLog(username, method, path, ip, duration, "1",
error.getMessage().substring(0, Math.min(error.getMessage().length(), 500)), finalInfo))
.then(Mono.error(error));
});
}
private OperationInfo findOperationInfo(String method, String path) {
String key = method + ":" + path;
if (OPERATION_MAPPING.containsKey(key)) {
return OPERATION_MAPPING.get(key);
}
private boolean isWriteOperation(String method) {
return HttpMethod.POST.name().equals(method) ||
HttpMethod.PUT.name().equals(method) ||
HttpMethod.DELETE.name().equals(method) ||
HttpMethod.PATCH.name().equals(method);
}
for (Map.Entry<String, OperationInfo> entry : OPERATION_MAPPING.entrySet()) {
String mappingKey = entry.getKey();
if (key.startsWith(mappingKey)) {
private OperationInfo findPrefixMatch(String key) {
for (Map.Entry<String, OperationInfo> entry : PREFIX_MAPPING.entrySet()) {
if (key.startsWith(entry.getKey())) {
return entry.getValue();
}
}
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 {
final String module;
final String operation;
@@ -11,6 +11,11 @@ import org.springframework.security.config.annotation.web.reactive.EnableWebFlux
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
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
@EnableWebFluxSecurity
@@ -29,6 +34,20 @@ public class SecurityConfig {
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
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
String[] activeProfiles = environment.getActiveProfiles();
@@ -41,6 +60,7 @@ public class SecurityConfig {
activeProfiles.length > 0 ? String.join(",", activeProfiles) : "default", isDevOrTest);
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
@@ -53,6 +53,8 @@ public interface ISysUserService {
Mono<SysUser> changePassword(Long userId, String oldPassword, String newPassword);
Mono<Boolean> verifyPassword(Long userId, String password);
Mono<Void> updateRoleIdToNullByRoleId(Long roleId);
Mono<Void> assignRolesToUser(Long userId, java.util.List<Long> roleIds);
@@ -120,6 +120,9 @@ public class SysPermissionService implements ISysPermissionService {
@Override
public Flux<SysPermission> findByRoleIds(List<Long> roleIds) {
if (roleIds == null || roleIds.isEmpty()) {
return Flux.empty();
}
return permissionRepository.findByRoleIds(roleIds);
}
@@ -83,7 +83,6 @@ public class SysRoleService implements ISysRoleService {
@Override
public Mono<SysRole> createRole(CreateRoleCommand command) {
SysRole role = new SysRole();
role.generateId();
role.setRoleName(command.roleName());
role.setRoleKey(command.roleKey());
role.setRoleSort(command.roleSort());
@@ -97,7 +97,6 @@ public class SysUserService implements ISysUserService {
logger.info("SysUserService.createUser - 用户名: {}, 密码前缀: {}",
user.getUsername(),
user.getPassword() != null ? user.getPassword().substring(0, 7) : "null");
user.generateId();
if (user.getPassword() != null && !user.getPassword().startsWith("$2a$")
&& !user.getPassword().startsWith("$2b$")) {
logger.info("密码不以$2a$或$2b$开头,重新编码");
@@ -117,7 +116,6 @@ public class SysUserService implements ISysUserService {
@Override
public Mono<SysUser> createUser(CreateUserCommand command) {
SysUser user = new SysUser();
user.generateId();
user.setUsername(command.username().getValue());
user.setPassword(passwordEncoder.encode(command.password().getValue()));
user.setEmail(command.email().getValue());
@@ -204,6 +202,13 @@ public class SysUserService implements ISysUserService {
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
public Mono<SysUser> changePassword(Long userId, String oldPassword, String newPassword) {
return userRepository.findById(userId)
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.sys.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/**
* 认证响应DTO
*
@@ -20,13 +22,21 @@ public class AuthResponse {
@Schema(description = "用户名", example = "admin")
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(String token, Long userId, String username) {
public AuthResponse(String token, Long userId, String username, List<String> roles, List<String> permissions) {
this.token = token;
this.userId = userId;
this.username = username;
this.roles = roles;
this.permissions = permissions;
}
public String getToken() {
@@ -52,4 +62,20 @@ public class AuthResponse {
public void setUsername(String 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,6 +8,7 @@ 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.service.ISysUserService;
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.IpLocationParser;
import cn.novalon.gym.manage.common.util.StatusConstants;
@@ -28,6 +29,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -50,6 +52,7 @@ public class SysAuthHandler {
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final ISysLoginLogService loginLogService;
private final ISysPermissionService permissionService;
private final UserAgentParser userAgentParser;
private final IpLocationParser ipLocationParser;
@@ -60,11 +63,13 @@ public class SysAuthHandler {
public SysAuthHandler(ISysUserService userService,
@Qualifier("passwordEncoder") PasswordEncoder passwordEncoder,
JwtTokenProvider jwtTokenProvider, ISysLoginLogService loginLogService,
ISysPermissionService permissionService,
UserAgentParser userAgentParser, IpLocationParser ipLocationParser) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
this.jwtTokenProvider = jwtTokenProvider;
this.loginLogService = loginLogService;
this.permissionService = permissionService;
this.userAgentParser = userAgentParser;
this.ipLocationParser = ipLocationParser;
@@ -126,29 +131,49 @@ public class SysAuthHandler {
}
return userService.getUserRoles(user.getId())
.map(role -> role.getRoleKey())
.collectList()
.flatMap(roleKeys -> {
String token = jwtTokenProvider
.generateToken(
.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 -> {
String token = jwtTokenProvider
.generateToken(
user.getUsername(),
user.getId(),
roleKeys);
logger.info("用户登录成功: username={}, userId={}, roles={}",
user.getUsername(),
user.getId(),
roleKeys);
recordLoginLog(loginRequest
.getUsername(),
clientIp,
"0", "登录成功",
userAgent);
AuthResponse response = new AuthResponse(
token,
user.getId(),
user.getUsername());
return ServerResponse.ok()
.bodyValue(response);
logger.info("用户登录成功: username={}, userId={}, roles={}, permissions={}",
user.getUsername(),
user.getId(),
roleKeys,
permCodes.size());
recordLoginLog(loginRequest.getUsername(),
clientIp,
"0", "登录成功",
userAgent);
AuthResponse response = new AuthResponse(
token,
user.getId(),
user.getUsername(),
roleKeys,
permCodes);
return ServerResponse.ok()
.bodyValue(response);
});
});
})
.switchIfEmpty(Mono.defer(() -> {
@@ -190,6 +215,50 @@ 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) {
try {
SysLoginLog loginLog = new SysLoginLog();
@@ -1,7 +1,11 @@
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.SysRole;
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.tags.Tag;
import org.springframework.http.HttpStatus;
@@ -22,10 +26,19 @@ import java.util.List;
@Tag(name = "权限管理", description = "权限相关操作")
public class SysPermissionHandler {
private final ISysPermissionService permissionService;
private static final Long BUILTIN_ROLE_ID = 1L;
public SysPermissionHandler(ISysPermissionService permissionService) {
private final ISysPermissionService permissionService;
private final ISysRoleService roleService;
private final ISysUserService userService;
private final AuthUtil authUtil;
public SysPermissionHandler(ISysPermissionService permissionService, ISysRoleService roleService,
ISysUserService userService, AuthUtil authUtil) {
this.permissionService = permissionService;
this.roleService = roleService;
this.userService = userService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有权限", description = "获取系统中所有权限列表")
@@ -97,12 +110,27 @@ public class SysPermissionHandler {
.body(permissionService.getPermissionsByRoleId(roleId), SysPermission.class);
}
@Operation(summary = "为角色分配权限", description = "为指定角色分配权限列表")
@Operation(summary = "为角色分配权限", description = "为指定角色分配权限列表,需验证管理员密码,超级管理员角色不可被分配")
public Mono<ServerResponse> assignPermissionsToRole(ServerRequest request) {
Long roleId = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(AssignPermissionsRequest.class)
.flatMap(req -> permissionService.assignPermissionsToRole(roleId, req.permissionIds()))
.then(ServerResponse.ok().build());
return verifyAdminPassword(request)
.flatMap(valid -> {
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) {}
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.sys.handler.role;
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.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
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.RoleUpdateRequest;
@@ -30,12 +32,18 @@ import java.util.Map;
@Tag(name = "角色管理", description = "角色相关操作")
public class SysRoleHandler {
private static final Long BUILTIN_ROLE_ID = 1L;
private final ISysRoleService roleService;
private final Validator validator;
private final AuthUtil authUtil;
private final ISysUserService userService;
public SysRoleHandler(ISysRoleService roleService, Validator validator) {
public SysRoleHandler(ISysRoleService roleService, Validator validator, AuthUtil authUtil, ISysUserService userService) {
this.roleService = roleService;
this.validator = validator;
this.authUtil = authUtil;
this.userService = userService;
}
@Operation(summary = "获取所有角色", description = "获取系统中所有角色列表")
@@ -115,30 +123,39 @@ public class SysRoleHandler {
});
}
@Operation(summary = "更新角色", description = "更新角色信息")
@Operation(summary = "更新角色", description = "更新角色信息需验证管理员密码超级管理员角色不可被编辑")
@OperationLog(operation = "更新角色", module = "角色管理")
public Mono<ServerResponse> updateRole(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(RoleUpdateRequest.class)
.map(req -> UpdateRoleCommand.of(
id,
req.getRoleName(),
req.getRoleKey(),
req.getRoleSort(),
req.getStatus()
))
.flatMap(roleService::updateRole)
.flatMap(updatedRole -> ServerResponse.ok().bodyValue(updatedRole))
.switchIfEmpty(ServerResponse.notFound().build());
return verifyAdminPassword(request)
.flatMap(valid -> {
if (!valid) return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误");
if (BUILTIN_ROLE_ID.equals(id)) return ServerResponse.badRequest().bodyValue("超级管理员角色不可编辑");
return request.bodyToMono(RoleUpdateRequest.class)
.map(req -> UpdateRoleCommand.of(
id, req.getRoleName(), req.getRoleKey(),
req.getRoleSort(), req.getStatus()
))
.flatMap(roleService::updateRole)
.flatMap(updatedRole -> ServerResponse.ok().bodyValue(updatedRole))
.switchIfEmpty(ServerResponse.notFound().build());
});
}
@Operation(summary = "删除角色", description = "逻辑删除角色")
@Operation(summary = "删除角色", description = "逻辑删除角色需验证管理员密码超级管理员角色不可被删除")
@OperationLog(operation = "删除角色", module = "角色管理")
public Mono<ServerResponse> deleteRole(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return roleService.logicalDeleteRole(id)
.flatMap(role -> ServerResponse.ok().bodyValue(role))
.switchIfEmpty(ServerResponse.notFound().build());
return verifyAdminPassword(request)
.flatMap(valid -> {
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 = "恢复被逻辑删除的角色")
@@ -148,4 +165,13 @@ public class SysRoleHandler {
.flatMap(role -> ServerResponse.ok().bodyValue(role))
.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,6 +2,7 @@ package cn.novalon.gym.manage.sys.handler.user;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
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.sys.dto.request.AssignRolesRequest;
import cn.novalon.gym.manage.sys.dto.request.PasswordChangeRequest;
@@ -42,10 +43,12 @@ public class SysUserHandler {
private static final Logger logger = LoggerFactory.getLogger(SysUserHandler.class);
private final ISysUserService userService;
private final Validator validator;
private final AuthUtil authUtil;
public SysUserHandler(ISysUserService userService, Validator validator) {
public SysUserHandler(ISysUserService userService, Validator validator, AuthUtil authUtil) {
this.userService = userService;
this.validator = validator;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有用户", description = "获取系统中所有用户列表")
@@ -152,39 +155,63 @@ public class SysUserHandler {
});
}
@Operation(summary = "更新用户", description = "更新用户信息")
@Operation(summary = "更新用户", description = "更新用户信息需验证管理员密码超级管理员不可编辑")
@OperationLog(operation = "更新用户", module = "用户管理")
public Mono<ServerResponse> updateUser(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(UserUpdateRequest.class)
.map(req -> {
boolean clearRole = Boolean.TRUE.equals(req.getClearRole()) ||
(req.getRoleId() == null && req.getClearRole() != null);
return UpdateUserCommand.of(
id,
null,
null,
req.getEmail(),
req.getRoleId(),
req.getStatus(),
clearRole
);
})
.flatMap(userService::updateUser)
.flatMap(user -> ServerResponse.ok().bodyValue(user))
.switchIfEmpty(ServerResponse.notFound().build());
return verifyAdminPassword(request)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误");
}
return userService.findById(id)
.flatMap(user -> {
if ("admin".equals(user.getUsername())) {
return Mono.<ServerResponse>error(new RuntimeException("超级管理员不可编辑"));
}
return request.bodyToMono(UserUpdateRequest.class)
.map(req -> {
boolean clearRole = Boolean.TRUE.equals(req.getClearRole()) ||
(req.getRoleId() == null && req.getClearRole() != null);
return UpdateUserCommand.of(
id, null, null, req.getEmail(),
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 = "用户管理")
public Mono<ServerResponse> deleteUser(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return userService.findById(id)
.flatMap(user -> userService.deleteUser(id)
.then(ServerResponse.noContent().build()))
.switchIfEmpty(Mono.error(new RuntimeException("User not found")))
return verifyAdminPassword(request)
.flatMap(valid -> {
if (!valid) {
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 -> {
if (ex.getMessage().contains("not found")) {
String msg = ex.getMessage();
if ("超级管理员不可删除".equals(msg) || "超级管理员不可编辑".equals(msg)) {
return ServerResponse.badRequest().bodyValue(msg);
}
if ("User not found".equals(msg)) {
return ServerResponse.notFound().build();
}
return Mono.error(ex);
@@ -258,16 +285,37 @@ public class SysUserHandler {
.flatMap(exists -> ServerResponse.ok().bodyValue(exists));
}
@Operation(summary = "为用户分配角色", description = "为指定用户分配角色列表")
@Operation(summary = "为用户分配角色", description = "为指定用户分配角色列表需验证管理员密码超级管理员不可分配")
@OperationLog(operation = "分配角色", module = "用户管理")
public Mono<ServerResponse> assignRoles(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(AssignRolesRequest.class)
.flatMap(req -> userService.assignRolesToUser(id, req.getRoleIdsAsLong()))
.then(ServerResponse.ok().build())
.onErrorResume(error -> {
logger.error("分配角色失败", error);
return ServerResponse.status(500).bodyValue("分配角色失败: " + error.getMessage());
return verifyAdminPassword(request)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误");
}
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);
});
}
@@ -277,4 +325,16 @@ public class SysUserHandler {
return ServerResponse.ok()
.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);
}
}