diff --git a/gym-manage-api/gym-checkIn/.gitignore b/gym-manage-api/gym-checkIn/.gitignore new file mode 100644 index 0000000..8220137 --- /dev/null +++ b/gym-manage-api/gym-checkIn/.gitignore @@ -0,0 +1,48 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Virtual machine crash logs +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project + +# OS +.DS_Store +Thumbs.db diff --git a/gym-manage-api/gym-checkIn/pom.xml b/gym-manage-api/gym-checkIn/pom.xml new file mode 100644 index 0000000..c23489b --- /dev/null +++ b/gym-manage-api/gym-checkIn/pom.xml @@ -0,0 +1,252 @@ + + + 4.0.0 + + + cn.novalon.gym.manage + gym-manage-api + 1.0.0 + + + gym-checkIn + jar + + Gym CheckIn + Check-In Management Module - Member Attendance Services + + + + cn.novalon.gym.manage + manage-common + ${project.version} + + + cn.novalon.gym.manage + manage-db + ${project.version} + + + cn.novalon.gym.manage + gym-member + ${project.version} + + + cn.novalon.gym.manage + gym-groupCourse + ${project.version} + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-aop + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.data + spring-data-commons + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + io.projectreactor + reactor-test + test + + + io.github.resilience4j + resilience4j-spring-boot3 + + + io.github.resilience4j + resilience4j-reactor + + + org.testcontainers + testcontainers + 1.21.4 + test + + + org.testcontainers + postgresql + 1.21.4 + test + + + org.testcontainers + junit-jupiter + 1.21.4 + test + + + com.h2database + h2 + test + + + io.r2dbc + r2dbc-h2 + test + + + org.postgresql + r2dbc-postgresql + test + + + cn.hutool + hutool-all + 5.8.25 + + + com.google.zxing + core + 3.5.1 + + + + + com.google.zxing + javase + 3.5.1 + + + + org.springframework.boot + spring-boot-starter-websocket + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + package + + jar + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 21 + 21 + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + org.projectlombok + lombok + ${lombok.version} + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + check + verify + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.60 + + + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.6.0 + + + com.github.spotbugs + spotbugs + 4.8.6 + + + + + spotbugs-check + verify + + check + + + + + Max + High + true + spotbugs-exclude.xml + + + + + diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/config/QRCodeConfig.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/config/QRCodeConfig.java new file mode 100644 index 0000000..dd44c33 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/config/QRCodeConfig.java @@ -0,0 +1,46 @@ +package cn.novalon.gym.manage.checkIn.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "qr.config") +public class QRCodeConfig { + + /** + * 二维码宽度(像素) + */ + private Integer width = 300; + + /** + * 二维码高度(像素) + */ + private Integer height = 300; + + /** + * 白边宽度 + */ + private Integer margin = 1; + + /** + * 容错率:L, M, Q, H + */ + private String errorCorrection = "M"; + + /** + * 图片格式:png, jpg + */ + private String format = "png"; + + /** + * 是否启用Logo + */ + private Boolean logoEnabled = false; + + /** + * Logo路径 + */ + private String logoPath = ""; +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/config/WebSocketConfig.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/config/WebSocketConfig.java new file mode 100644 index 0000000..e4d6ea1 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/config/WebSocketConfig.java @@ -0,0 +1,43 @@ +package cn.novalon.gym.manage.checkIn.config; + +import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; +import org.springframework.web.reactive.socket.WebSocketHandler; +import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class WebSocketConfig { + + @Autowired + private MyWebSocketHandler myWebSocketHandler; + + /** + * 注册 WebSocket 路由映射 + * 路径对应前端连接的 ws://xxx/webSocket/checkIn + */ + @Bean + public HandlerMapping webSocketMapping() { + Map map = new HashMap<>(); + map.put("/webSocket/checkIn", myWebSocketHandler); + + SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); + mapping.setUrlMap(map); + mapping.setOrder(10); // 设置优先级 + return mapping; + } + + /** + * 注册 WebSocket 处理器适配器(必须) + */ + @Bean + public WebSocketHandlerAdapter handlerAdapter() { + return new WebSocketHandlerAdapter(); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/constant/QRRedisKey.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/constant/QRRedisKey.java new file mode 100644 index 0000000..8baa890 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/constant/QRRedisKey.java @@ -0,0 +1,43 @@ +package cn.novalon.gym.manage.checkIn.constant; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * 打卡模块 Redis 键常量 + * + * @author 付嘉 + * @date 2026-05-30 + */ +public final class QRRedisKey { + + private static final String SEPARATOR = ":"; + private static final String QRCODE_USER_DAILY = "qrcode:user:daily"; + private static final String QRCODE_CONTENT = "QR_"; + private QRRedisKey() { + // 私有构造,防止实例化 + } + + /** + * 用户当日二维码 + * 格式:qrcode:user:daily:{userId}:{date} + * 示例:qrcode:user:daily:1001:2026-05-30 + */ + public static String qrcodeUserDaily(Long userId, LocalDate date) { + return QRCODE_USER_DAILY + SEPARATOR + userId + SEPARATOR + date; + } + + /** + * 用户当日二维码(今天) + */ + public static String qrcodeUserToday(Long userId) { + return qrcodeUserDaily(userId, LocalDate.now()); + } + + /** + * 生成二维码内容(每个用户每次调用都不同) + */ + public static String generateQrcodeContent() { + return QRCODE_CONTENT + UUID.randomUUID().toString().replace("-", ""); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/dto/QRCodeDto.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/dto/QRCodeDto.java new file mode 100644 index 0000000..4ae9b80 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/dto/QRCodeDto.java @@ -0,0 +1,13 @@ +package cn.novalon.gym.manage.checkIn.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class QRCodeDto { + + private String qrContent; + + private boolean isUsed; +} diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/entity/SignInRecord.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/entity/SignInRecord.java new file mode 100644 index 0000000..4946012 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/entity/SignInRecord.java @@ -0,0 +1,209 @@ +package cn.novalon.gym.manage.checkIn.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +/** + * 会员到店签到记录实体 + * + * @author 付嘉 + * @date 2026-06-08 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("sign_in_record") +public class SignInRecord { + + /** + * 自增主键 + */ + @Id + private Long id; + + /** + * 会员ID,关联member表 + */ + @Column("member_id") + private Long memberId; + + /** + * 签到时使用的会员卡ID + */ + @Column("member_card_id") + private Long memberCardId; + + /** + * 签到入场时间 + */ + @Column("sign_in_time") + private LocalDateTime signInTime; + + /** + * 签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别 + */ + @Column("sign_in_type") + private String signInType; + + /** + * 签到状态:SUCCESS-成功,FAILED-失败 + */ + @Column("sign_in_status") + private String signInStatus; + + /** + * JSONB格式,存储会员卡验证时的快照数据 + */ + @Column("verification_details") + private String verificationDetails; + + /** + * 失败时的具体原因文案 + */ + @Column("fail_reason") + private String failReason; + + /** + * 操作人ID(前台人员),自助签到时为NULL + */ + @Column("operator_id") + private Long operatorId; + + /** + * 操作人姓名冗余 + */ + @Column("operator_name") + private String operatorName; + + /** + * 签到设备标识或型号 + */ + @Column("device_info") + private String deviceInfo; + + /** + * 客户端IP地址 + */ + @Column("ip_address") + private String ipAddress; + + /** + * 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端 + */ + @Column("source") + private String source; + + /** + * 软删除标识:false-未删除,true-已删除 + */ + @Column("is_delete") + private Boolean isDelete; + + /** + * 记录创建时间 + */ + @CreatedDate + @Column("created_at") + private LocalDateTime createdAt; + + /** + * 记录更新时间 + */ + @LastModifiedDate + @Column("updated_at") + private LocalDateTime updatedAt; + + // ========== 常量定义 ========== + + /** + * 签到类型常量 + */ + public static final class SignInType { + /** 扫码签到 */ + public static final String QR_CODE = "QR_CODE"; + /** 手动签到 */ + public static final String MANUAL = "MANUAL"; + /** 人脸识别 */ + public static final String FACE = "FACE"; + + private SignInType() {} + } + + /** + * 签到状态常量 + */ + public static final class SignInStatus { + /** 成功 */ + public static final String SUCCESS = "SUCCESS"; + /** 失败 */ + public static final String FAILED = "FAILED"; + + private SignInStatus() {} + } + + /** + * 签到来源常量 + */ + public static final class Source { + /** 小程序扫码 */ + public static final String MINI_PROGRAM = "MINI_PROGRAM"; + /** 后台管理端手动签到 */ + public static final String PC_BACKEND = "PC_BACKEND"; + + private Source() {} + } + + // ========== 辅助方法 ========== + + /** + * 判断签到是否成功 + */ + public boolean isSuccess() { + return SignInStatus.SUCCESS.equals(this.signInStatus); + } + + /** + * 判断签到是否失败 + */ + public boolean isFailed() { + return SignInStatus.FAILED.equals(this.signInStatus); + } + + /** + * 判断是否为扫码签到 + */ + public boolean isQrCodeSign() { + return SignInType.QR_CODE.equals(this.signInType); + } + + /** + * 判断是否已删除 + */ + public boolean isDeleted() { + return Boolean.TRUE.equals(this.isDelete); + } + + /** + * 软删除 + */ + public void softDelete() { + this.isDelete = true; + } + + /** + * 恢复删除 + */ + public void restore() { + this.isDelete = false; + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/handler/CheckInHandler.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/handler/CheckInHandler.java new file mode 100644 index 0000000..ceae33f --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/handler/CheckInHandler.java @@ -0,0 +1,193 @@ +package cn.novalon.gym.manage.checkIn.handler; + +import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl; +import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler; +import cn.novalon.gym.manage.sys.util.AuthUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +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.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CheckInHandler { + + private final AuthUtil authUtil; + private final CheckServiceImpl checkService; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * 签到 + * + * POST /api/checkIn + * + */ + public Mono checkIn(ServerRequest request) { + + Long memberId = authUtil.getMemberIdOrThrow(request); + return request.bodyToMono(Map.class) + .flatMap(body -> { + String qrContent = (String) body.get("qrContent"); + log.info("收到签到请求, memberId: {}, qrContent: {}", memberId, qrContent); + boolean messageToClient = MyWebSocketHandler.sendMessageToClient(qrContent, "正在进行签到"); + log.info("WebSocket 推送结果: {}", messageToClient); + return checkService.checkIn(memberId, qrContent) + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(result)); + }) + .onErrorResume(e -> { + log.error("签到失败", e); + return ServerResponse.status(HttpStatus.BAD_REQUEST) + .bodyValue(Map.of("code", 400, "message", e.getMessage())); + }); + } + + /** + * 获取二维码 + * + * GET /api/checkin/qrcode + * + */ + public Mono getQRCode(ServerRequest request) { + + Long memberId = authUtil.getMemberIdOrThrow(request); + + log.info("收到用户{}获取二维码请求", memberId); + + return checkService.getQRCode(memberId) + .flatMap(qrCodeVo -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(qrCodeVo)); + } + + /** + * 查询签到记录列表 + * + * GET /api/checkIn/records + * + * @param request + * @return + */ + public Mono getSignInRecords(ServerRequest request) { + Long memberId = authUtil.getMemberIdOrThrow(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("查询签到记录, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate); + + return checkService.getSignInRecords(memberId, startDate, endDate) + .collectList() + .flatMap(records -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("code", 200, "message", "success", "data", records))); + } + + /** + * 查询单条签到记录 + * + * GET /api/checkIn/records/{id} + * + * @param request + * @return + */ + public Mono getSignInRecordById(ServerRequest request) { + Long id = Long.parseLong(request.pathVariable("id")); + + log.info("查询签到记录详情, id: {}", id); + + return checkService.getSignInRecordById(id) + .flatMap(record -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("code", 200, "message", "success", "data", record))) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + /** + * 获取签到统计 + * + * GET /api/checkIn/statistics + * + * @param request + * @return + */ + public Mono getSignInStatistics(ServerRequest request) { + Long memberId = authUtil.getMemberIdOrThrow(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("查询签到统计, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate); + + return checkService.getSignInStats(memberId, startDate, endDate) + .flatMap(stats -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("code", 200, "message", "success", "data", stats))); + } + + /** + * 导出签到记录 + * + * GET /api/checkIn/records/export + * + * @param request + * @return + */ + public Mono exportSignInRecords(ServerRequest request) { + Long memberId = authUtil.getMemberIdOrThrow(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("导出签到记录, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate); + + String filename = "签到记录_" + startDateStr + "_" + endDateStr + ".csv"; + + return checkService.exportSignInRecords(memberId, startDate, endDate) + .flatMap(bytes -> ServerResponse.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8")) + .bodyValue(bytes)); + } + + /** + * 获取每日签到统计 + * + * GET /api/checkIn/daily-stats + * + * @param request + * @return + */ + public Mono getDailySignInStats(ServerRequest request) { + String dateStr = request.queryParam("date").orElse(null); + LocalDate date = dateStr != null ? LocalDate.parse(dateStr, DATE_FORMATTER) : LocalDate.now(); + + log.info("查询每日签到统计, date: {}", date); + + return checkService.getDailySignInStats(date) + .flatMap(stats -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("code", 200, "message", "success", "data", stats))); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/repository/SignInRecordRepository.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/repository/SignInRecordRepository.java new file mode 100644 index 0000000..e58cb59 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/repository/SignInRecordRepository.java @@ -0,0 +1,101 @@ +package cn.novalon.gym.manage.checkIn.repository; + +import cn.novalon.gym.manage.checkIn.entity.SignInRecord; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** + * 签到记录 Repository + * + * @author 付嘉 + * @date 2026-06-08 + */ +@Repository +public interface SignInRecordRepository extends R2dbcRepository { + + /** + * 查询会员某天的签到记录 + */ + @Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false") + Mono findByMemberIdAndDate(Long memberId, LocalDateTime startTime, LocalDateTime endTime); + + /** + * 查询会员的签到记录列表 + */ + @Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND is_delete = false ORDER BY sign_in_time DESC") + Flux findByMemberId(Long memberId); + + /** + * 统计会员某天的签到次数 + */ + @Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false") + Mono countByMemberIdAndDate(Long memberId, LocalDateTime startTime, LocalDateTime endTime); + + /** + * 插入签到记录 + */ + @Query("INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, verification_details, fail_reason, source, created_at, updated_at, is_delete) " + + "VALUES (:memberId, :memberCardId, :signInTime, :signInType, :signInStatus, :verificationDetails, :failReason, :source, NOW(), NOW(), false)") + Mono insertRecord(Long memberId, Long memberCardId, LocalDateTime signInTime, + String signInType, String signInStatus, String verificationDetails, + String failReason, String source); + + /** + * 根据会员ID和时间范围查询签到记录 + */ + @Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC") + Flux findByMemberIdAndTimeRange(Long memberId, 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 findByTimeRange(LocalDateTime startTime, LocalDateTime endTime); + + /** + * 统计会员在时间范围内的签到次数 + */ + @Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false") + Mono countByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime); + + /** + * 统计时间范围内的签到次数 + */ + @Query("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false") + Mono countByTimeRange(LocalDateTime startTime, LocalDateTime endTime); + + /** + * 统计会员在时间范围内的成功签到次数 + */ + @Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false") + Mono countSuccessByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime); + + /** + * 统计时间范围内的成功签到次数 + */ + @Query("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false") + Mono countSuccessByTimeRange(LocalDateTime startTime, LocalDateTime endTime); + + /** + * 统计时间范围内签到的独立会员数 + */ + @Query("SELECT COUNT(DISTINCT member_id) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false") + Mono countDistinctMembersByTimeRange(LocalDateTime startTime, LocalDateTime endTime); + + /** + * 获取会员在时间范围内的首次签到时间 + */ + @Query("SELECT MIN(sign_in_time) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false") + Mono getFirstSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime); + + /** + * 获取会员在时间范围内的最后签到时间 + */ + @Query("SELECT MAX(sign_in_time) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false") + Mono getLastSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime); +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/ICheckInService.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/ICheckInService.java new file mode 100644 index 0000000..5b4894f --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/ICheckInService.java @@ -0,0 +1,81 @@ +package cn.novalon.gym.manage.checkIn.service; + +import cn.novalon.gym.manage.checkIn.vo.QRCodeVo; +import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO; +import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; + +/** + * 签到服务接口 + * + * @author 付嘉 + * @date 2026-06-08 + */ +public interface ICheckInService { + + /** + * 获取签到二维码 + * + * @param memberId 会员ID + * @return 二维码VO + */ + Mono getQRCode(Long memberId); + + /** + * 扫码签到 + * + * @param memberId 会员ID + * @param qrContent 二维码内容 + * @return 签到结果JSON字符串 + */ + Mono checkIn(Long memberId, String qrContent); + + /** + * 查询会员签到记录列表 + * + * @param memberId 会员ID + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 签到记录列表 + */ + Flux getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime); + + /** + * 根据ID查询签到记录 + * + * @param id 签到记录ID + * @return 签到记录VO + */ + Mono getSignInRecordById(Long id); + + /** + * 获取会员签到统计 + * + * @param memberId 会员ID + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 签到统计VO + */ + Mono getSignInStats(Long memberId, LocalDate startTime, LocalDate endTime); + + /** + * 导出会员签到记录 + * + * @param memberId 会员ID + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return CSV格式的字节数组 + */ + Mono exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime); + + /** + * 获取每日签到统计 + * + * @param date 日期 + * @return 签到统计VO + */ + Mono getDailySignInStats(LocalDate date); +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/impl/CheckServiceImpl.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/impl/CheckServiceImpl.java new file mode 100644 index 0000000..ad41210 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/impl/CheckServiceImpl.java @@ -0,0 +1,439 @@ +package cn.novalon.gym.manage.checkIn.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.json.JSONUtil; +import cn.novalon.gym.manage.checkIn.config.QRCodeConfig; +import cn.novalon.gym.manage.checkIn.constant.QRRedisKey; +import cn.novalon.gym.manage.checkIn.entity.SignInRecord; +import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository; +import cn.novalon.gym.manage.checkIn.service.ICheckInService; +import cn.novalon.gym.manage.checkIn.vo.QRCodeVo; +import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO; +import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO; +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.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.MemberCardRepository; +import cn.hutool.extra.qrcode.QrCodeUtil; +import cn.hutool.extra.qrcode.QrConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CheckServiceImpl implements ICheckInService { + + private final QRCodeConfig qrCodeConfig; + private final RedisUtil redisUtil; + private final MemberCardRecordRepository memberCardRecordRepository; + private final MemberCardRepository memberCardRepository; + private final SignInRecordRepository signInRecordRepository; + private final IGroupCourseBookingService groupCourseBookingService; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public Mono getQRCode(Long memberId) { + log.info("开始查询会员信息, memberId: {}", memberId); + + return findValidMemberCard(memberId) + .flatMap(cardRecord -> { + log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId()); + + log.info("开始生成二维码"); + String qrContent = QRRedisKey.generateQrcodeContent(); + Map redisMap = new HashMap<>(); + redisMap.put("qrContent", qrContent); + redisMap.put("isUsed", false); + redisMap.put("memberId", memberId); + redisMap.put("memberCardRecordId", cardRecord.getMemberCardRecordId()); + + return redisUtil.setWithExpire( + RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), + redisMap, + getSecondsUntilEndOfDay() + ) + .then(Mono.fromSupplier(() -> { + String qrCodeBase64 = QrCodeUtil.generateAsBase64(qrContent, + BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png"); + return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now()); + })); + }) + .switchIfEmpty(Mono.error(new RuntimeException("该会员没有可用的会员卡"))); + } + + @Override + public Mono checkIn(Long memberId, String qrContent) { + String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(); + + // 先检查当天是否已经签到(从数据库查询,作为重复签到的额外保障) + return checkTodayAlreadySignedIn(memberId) + .flatMap(existingRecord -> { + String checkInTime = existingRecord.getSignInTime().format(DATE_FORMATTER); + log.error("重复签到, memberId: {}", memberId); + MyWebSocketHandler.sendFailure(qrContent, "您已经在" + checkInTime + "完成签到,请勿重复签到"); + return Mono.error(new RuntimeException("您已经在" + checkInTime + "完成签到,请勿重复签到")); + }) + .then(Mono.defer(() -> redisUtil.get(key))) + .flatMap(cachedObj -> { + if (cachedObj != null) { + Map map; + if (cachedObj instanceof Map) { + map = (Map) cachedObj; + } else if (cachedObj instanceof String) { + map = JSONUtil.parseObj((String) cachedObj); + } else { + MyWebSocketHandler.sendFailure(qrContent, "二维码数据格式错误"); + return Mono.error(new RuntimeException("二维码数据格式错误")); + } + if (map.get("qrContent").equals(qrContent)) { + if ((boolean) map.get("isUsed")) { + String checkInTime = String.valueOf(map.get("checkInTime")); + log.error("重复签到(缓存), memberId: {}", memberId); + MyWebSocketHandler.sendFailure(qrContent, "您已经在" + checkInTime + "完成签到,请勿重复签到"); + return Mono.error(new RuntimeException("您已经在" + checkInTime + "完成签到,请勿重复签到")); + } + log.info("二维码匹配成功,memberId: {}", memberId); + + Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue(); + + return processCheckIn(memberId, memberCardRecordId, map, qrContent); + } else { + MyWebSocketHandler.sendFailure(qrContent, "二维码无效"); + return Mono.error(new RuntimeException("二维码无效")); + } + } + MyWebSocketHandler.sendFailure(qrContent, "二维码已过期或不存在"); + return Mono.error(new RuntimeException("二维码已过期或不存在")); + }); + } + + /** + * 检查会员当天是否已经签到 + * @param memberId 会员ID + * @return 如果已签到返回签到记录,否则返回空 + */ + private Mono checkTodayAlreadySignedIn(Long memberId) { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX); + + return signInRecordRepository.findByMemberIdAndDate(memberId, startOfDay, endOfDay); + } + + /** + * 处理签到逻辑 + */ + private Mono processCheckIn(Long memberId, Long memberCardRecordId, Map redisMap, String qrContent) { + LocalDateTime now = LocalDateTime.now(); + + // 发送实时进度通知 + MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡..."); + + return memberCardRecordRepository.findById(memberCardRecordId) + .switchIfEmpty(Mono.defer(() -> { + MyWebSocketHandler.sendFailure(qrContent, "会员卡记录不存在"); + return Mono.error(new RuntimeException("会员卡记录不存在")); + })) + .flatMap(cardRecord -> { + if (!"ACTIVE".equals(cardRecord.getStatus().name())) { + MyWebSocketHandler.sendFailure(qrContent, "会员卡状态不正确"); + return Mono.error(new RuntimeException("会员卡状态不正确")); + } + + if (cardRecord.getExpireTime() != null && cardRecord.getExpireTime().isBefore(now)) { + MyWebSocketHandler.sendFailure(qrContent, "会员卡已过期"); + return Mono.error(new RuntimeException("会员卡已过期")); + } + + // 发送实时进度通知 + MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息..."); + + // 检查是否有需要签到的团课预约 + return validateBooking(memberId, now) + .then(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(cardRecord.getMemberCardId()) + .switchIfEmpty(Mono.defer(() -> { + MyWebSocketHandler.sendFailure(qrContent, "会员卡类型不存在"); + return Mono.error(new RuntimeException("会员卡类型不存在")); + })) + .flatMap(card -> { + // 发送实时进度通知 + MyWebSocketHandler.sendProgress(qrContent, "DEDUCT_USAGE", "正在扣减会员卡次数..."); + + return deductCardUsage(cardRecord, card) + .flatMap(updatedRecord -> { + redisMap.put("isUsed", true); + redisMap.put("checkInTime", now.format(DATE_FORMATTER)); + + return saveSignInRecord(memberId, cardRecord.getMemberCardRecordId(), card.getMemberCardId()) + .then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap)) + .then(Mono.defer(() -> { + String successMsg = buildSuccessResponse(now); + MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER)); + log.info("签到成功, memberId: {}, cardRecordId: {}", memberId, memberCardRecordId); + return Mono.just(successMsg); + })); + }); + })); + }); + } + + /** + * 验证预约信息 + */ + private Mono validateBooking(Long memberId, LocalDateTime now) { + return groupCourseBookingService.getBookingsByMemberId(memberId) + .filter(booking -> { + String status = booking.getStatus(); + LocalDateTime startTime = booking.getCourseStartTime(); + return "0".equals(status) && + startTime != null && + startTime.toLocalDate().equals(now.toLocalDate()) && + !startTime.isBefore(now.minusMinutes(30)); + }) + .collectList() + .flatMap(bookings -> { + if (bookings.isEmpty()) { + return Mono.empty(); + } + boolean hasValidBooking = bookings.stream() + .anyMatch(b -> { + LocalDateTime startTime = b.getCourseStartTime(); + return startTime != null && + !startTime.isBefore(now.minusMinutes(30)) && + !startTime.isAfter(now.plusMinutes(30)); + }); + if (hasValidBooking) { + log.info("会员{}有有效的团课预约", memberId); + } else { + log.warn("会员{}有预约但不在签到时间范围内", memberId); + } + return Mono.empty(); + }); + } + + /** + * 扣减会员卡使用次数/金额 + */ + private Mono deductCardUsage(MemberCardRecord record, MemberCard card) { + MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType()); + LocalDateTime now = LocalDateTime.now(); + + switch (cardType) { + case TIME_CARD: + if (record.getExpireTime() != null && record.getExpireTime().isBefore(now)) { + return Mono.error(new RuntimeException("时长卡已过期")); + } + return Mono.just(record); + case COUNT_CARD: + int currentTimes = record.getRemainingTimes() != null ? record.getRemainingTimes() : 0; + if (currentTimes < 1) { + return Mono.error(new RuntimeException("次卡剩余次数不足")); + } + record.setRemainingTimes(currentTimes - 1); + if (record.getRemainingTimes() == 0) { + record.setStatus(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.USED_UP); + } + return memberCardRecordRepository.save(record); + case STORED_VALUE_CARD: + double currentAmount = record.getRemainingAmount() != null ? record.getRemainingAmount() : 0.0; + if (currentAmount < 0.01) { + return Mono.error(new RuntimeException("储值卡余额不足")); + } + record.setRemainingAmount(Math.max(0, currentAmount - 1)); + if (record.getRemainingAmount() <= 0) { + record.setStatus(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.USED_UP); + } + return memberCardRecordRepository.save(record); + default: + return Mono.error(new RuntimeException("不支持的会员卡类型")); + } + } + + /** + * 保存签到记录 + */ + private Mono saveSignInRecord(Long memberId, Long memberCardRecordId, Long memberCardId) { + SignInRecord record = SignInRecord.builder() + .memberId(memberId) + .memberCardId(memberCardId) + .signInTime(LocalDateTime.now()) + .signInType(SignInRecord.SignInType.QR_CODE) + .signInStatus(SignInRecord.SignInStatus.SUCCESS) + .source(SignInRecord.Source.MINI_PROGRAM) + .isDelete(false) + .build(); + + return signInRecordRepository.save(record).then(); + } + + /** + * 构建成功响应 + */ + private String buildSuccessResponse(LocalDateTime dateTime) { + Map res = new HashMap<>(); + res.put("message", "签到成功"); + res.put("dateTime", dateTime.format(DATE_FORMATTER)); + return JSONUtil.toJsonStr(res); + } + + /** + * 查找会员的有效会员卡(优先选择有效期最早到期的) + */ + private Mono findValidMemberCard(Long memberId) { + return memberCardRecordRepository.findActiveCardsByMemberId(memberId) + .filter(record -> { + LocalDateTime expireTime = record.getExpireTime(); + return expireTime == null || expireTime.isAfter(LocalDateTime.now()); + }) + .sort((r1, r2) -> { + LocalDateTime e1 = r1.getExpireTime(); + LocalDateTime e2 = r2.getExpireTime(); + if (e1 == null && e2 == null) return 0; + if (e1 == null) return 1; + if (e2 == null) return -1; + return e1.compareTo(e2); + }) + .next(); + } + + // ==================== 签到记录管理功能 ==================== + + @Override + public Flux getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) { + LocalDateTime start = startTime.atStartOfDay(); + LocalDateTime end = endTime.atTime(LocalTime.MAX); + + return signInRecordRepository.findByMemberIdAndTimeRange(memberId, start, end) + .map(this::convertToVO); + } + + @Override + public Mono getSignInRecordById(Long id) { + return signInRecordRepository.findById(id) + .map(this::convertToVO); + } + + @Override + public Mono getSignInStats(Long memberId, 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]; + LocalDateTime first = (LocalDateTime) results[2]; + LocalDateTime last = (LocalDateTime) results[3]; + SignInStatsVO stats = new SignInStatsVO(); + stats.setTotalCount(total); + stats.setSuccessCount(success); + stats.setStartDate(startTime); + stats.setEndDate(endTime); + stats.setFirstSignInTime(first); + stats.setLastSignInTime(last); + stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0); + return stats; + }, + signInRecordRepository.countByMemberIdAndTimeRange(memberId, start, end), + signInRecordRepository.countSuccessByMemberIdAndTimeRange(memberId, start, end), + signInRecordRepository.getFirstSignInTime(memberId, start, end), + signInRecordRepository.getLastSignInTime(memberId, start, end) + ); + } + + @Override + public Mono exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) { + LocalDateTime start = startTime.atStartOfDay(); + LocalDateTime end = endTime.atTime(LocalTime.MAX); + + return signInRecordRepository.findByMemberIdAndTimeRange(memberId, start, end) + .map(record -> { + String status = "SUCCESS".equals(record.getSignInStatus()) ? "成功" : "失败"; + String type = "QR_CODE".equals(record.getSignInType()) ? "扫码签到" : + "MANUAL".equals(record.getSignInType()) ? "手动签到" : "人脸识别"; + return String.join(",", + record.getId().toString(), + record.getMemberId().toString(), + record.getMemberCardId() != null ? record.getMemberCardId().toString() : "", + record.getSignInTime() != null ? record.getSignInTime().format(DATE_FORMATTER) : "", + type, + status, + record.getFailReason() != null ? record.getFailReason() : "" + ); + }) + .collectList() + .map(rows -> { + List csvLines = new java.util.ArrayList<>(); + csvLines.add("签到记录ID,会员ID,会员卡ID,签到时间,签到方式,签到状态,失败原因"); + csvLines.addAll(rows); + return String.join("\n", csvLines).getBytes(java.nio.charset.StandardCharsets.UTF_8); + }); + } + + @Override + public Mono getDailySignInStats(LocalDate date) { + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.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(date); + stats.setEndDate(date); + 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) + ); + } + + /** + * 转换实体到VO + */ + private SignInRecordVO convertToVO(SignInRecord record) { + SignInRecordVO vo = new SignInRecordVO(); + vo.setId(record.getId()); + vo.setMemberId(record.getMemberId()); + vo.setMemberCardId(record.getMemberCardId()); + vo.setSignInTime(record.getSignInTime()); + vo.setSignInType(record.getSignInType()); + vo.setSignInStatus(record.getSignInStatus()); + vo.setFailReason(record.getFailReason()); + vo.setSource(record.getSource()); + vo.setCreatedAt(record.getCreatedAt()); + return vo; + } + + private long getSecondsUntilEndOfDay() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); + if (now.isAfter(endOfDay)) return 1; + return ChronoUnit.SECONDS.between(now, endOfDay); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/QRCodeVo.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/QRCodeVo.java new file mode 100644 index 0000000..d5dd3e2 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/QRCodeVo.java @@ -0,0 +1,23 @@ +package cn.novalon.gym.manage.checkIn.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@AllArgsConstructor +public class QRCodeVo { + + private String qrCodeBase64; + + private boolean isUsed; + + private String qrContent; + + private Integer width; + + private Integer height; + + private LocalDate createTime; +} diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/SignInRecordVO.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/SignInRecordVO.java new file mode 100644 index 0000000..60f8aac --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/SignInRecordVO.java @@ -0,0 +1,66 @@ +package cn.novalon.gym.manage.checkIn.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 签到记录VO + * + * @author 付嘉 + * @date 2026-06-08 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SignInRecordVO { + + /** + * 签到记录ID + */ + private Long id; + + /** + * 会员ID + */ + private Long memberId; + + /** + * 会员卡ID + */ + private Long memberCardId; + + /** + * 签到时间 + */ + private LocalDateTime signInTime; + + /** + * 签到类型:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别 + */ + private String signInType; + + /** + * 签到状态:SUCCESS-成功,FAILED-失败 + */ + private String signInStatus; + + /** + * 失败原因 + */ + private String failReason; + + /** + * 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端 + */ + private String source; + + /** + * 创建时间 + */ + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/SignInStatsVO.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/SignInStatsVO.java new file mode 100644 index 0000000..292e5fc --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/SignInStatsVO.java @@ -0,0 +1,62 @@ +package cn.novalon.gym.manage.checkIn.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 签到统计VO + * + * @author 付嘉 + * @date 2026-06-08 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SignInStatsVO { + + /** + * 统计开始日期 + */ + private LocalDate startDate; + + /** + * 统计结束日期 + */ + private LocalDate endDate; + + /** + * 总签到次数 + */ + private Long totalCount; + + /** + * 成功签到次数 + */ + private Long successCount; + + /** + * 成功率(百分比) + */ + private Double successRate; + + /** + * 独立会员数 + */ + private Long uniqueMemberCount; + + /** + * 首次签到时间 + */ + private LocalDateTime firstSignInTime; + + /** + * 最后签到时间 + */ + private LocalDateTime lastSignInTime; +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/websocket/MyWebSocketHandler.java b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/websocket/MyWebSocketHandler.java new file mode 100644 index 0000000..6b94625 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/websocket/MyWebSocketHandler.java @@ -0,0 +1,202 @@ +package cn.novalon.gym.manage.checkIn.websocket; + +import cn.hutool.json.JSONUtil; +import cn.novalon.gym.manage.checkIn.dto.QRCodeDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.socket.WebSocketHandler; +import org.springframework.web.reactive.socket.WebSocketSession; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * WebSocket 处理类,用于实时签到反馈 + * + * 技术要点: + * - 使用 Sinks 实现响应式消息推送 + * - 使用 ConcurrentHashMap 管理 qrContent 与 sink 的映射 + * - 支持实时推送签到进度和结果 + */ +@Slf4j +@Component +public class MyWebSocketHandler implements WebSocketHandler { + + /** + * qrContent -> Sink 映射,用于根据二维码内容找到对应的客户端连接 + */ + private static final Map> qrContentToSink = new ConcurrentHashMap<>(); + + /** + * 连接创建时间映射,用于超时清理 + */ + private static final Map qrContentToCreateTime = new ConcurrentHashMap<>(); + + /** + * 超时时间(秒),超过此时间未使用的连接将被清理 + */ + private static final long TIMEOUT_SECONDS = 300; + + @Override + public Mono handle(WebSocketSession session) { + String sessionId = session.getId(); + log.info("WebSocket 连接建立,sessionId: {}", sessionId); + + // 创建 sink,用于向客户端发送消息 + Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); + + // 订阅接收客户端消息(异步处理) + session.receive() + .doOnNext(message -> { + String payload = message.getPayloadAsText(); + log.debug("收到消息:sessionId={}, payload={}", sessionId, payload); + + try { + QRCodeDto qrCodeDto = JSONUtil.toBean(payload, QRCodeDto.class); + String qrContent = qrCodeDto.getQrContent(); + + if (qrContent != null && !qrContent.isEmpty()) { + // 绑定 qrContent 和 sink + qrContentToSink.put(qrContent, sink); + qrContentToCreateTime.put(qrContent, LocalDateTime.now()); + log.info("绑定成功: qrContent={}, sessionId={}", qrContent, sessionId); + + // 发送连接成功消息 + sink.tryEmitNext(buildMessage("CONNECTED", "签到监听已建立,请扫描二维码")); + } else { + sink.tryEmitNext(buildMessage("ERROR", "二维码内容为空")); + } + } catch (Exception e) { + log.error("解析消息失败,sessionId={}", sessionId, e); + sink.tryEmitNext(buildMessage("ERROR", "消息格式错误: " + e.getMessage())); + } + }) + .doOnError(e -> { + log.error("接收消息出错,sessionId={}", sessionId, e); + }) + .subscribe(); // 必须订阅,否则不会执行 + + // 发送流给客户端 + return session.send(sink.asFlux().map(session::textMessage)) + .doFinally(signal -> { + // 连接关闭时清理映射 + qrContentToSink.entrySet().removeIf(entry -> entry.getValue() == sink); + qrContentToCreateTime.entrySet().removeIf(entry -> { + Sinks.Many s = qrContentToSink.get(entry.getKey()); + return s == null || s == sink; + }); + log.info("WebSocket 连接关闭,sessionId={}", sessionId); + }); + } + + /** + * 向客户端发送消息 + * + * @param qrContent 二维码内容 + * @param message 消息内容 + * @return 是否发送成功 + */ + public static boolean sendMessageToClient(String qrContent, String message) { + // 先清理超时连接 + cleanupTimeoutConnections(); + + Sinks.Many sink = qrContentToSink.get(qrContent); + if (sink == null) { + log.warn("未找到绑定的连接,qrContent: {}", qrContent); + return false; + } + + Sinks.EmitResult result = sink.tryEmitNext(message); + if (result.isSuccess()) { + log.info("主动推送成功,qrContent: {}, message: {}", qrContent, message); + return true; + } else { + log.warn("推送失败,qrContent: {}, result: {}", qrContent, result); + return false; + } + } + + /** + * 发送签到进度消息 + * + * @param qrContent 二维码内容 + * @param step 进度步骤 + * @param message 进度消息 + */ + public static void sendProgress(String qrContent, String step, String message) { + String progressMessage = buildMessage("PROGRESS", message); + sendMessageToClient(qrContent, progressMessage); + log.debug("发送进度消息: qrContent={}, step={}, message={}", qrContent, step, message); + } + + /** + * 发送签到成功消息 + * + * @param qrContent 二维码内容 + * @param memberId 会员ID + * @param signInTime 签到时间 + */ + public static void sendSuccess(String qrContent, Long memberId, String signInTime) { + String successMessage = buildMessage("SUCCESS", "签到成功!欢迎光临\n会员ID: " + memberId + "\n签到时间: " + signInTime); + sendMessageToClient(qrContent, successMessage); + log.info("发送成功消息: qrContent={}, memberId={}", qrContent, memberId); + } + + /** + * 发送签到失败消息 + * + * @param qrContent 二维码内容 + * @param reason 失败原因 + */ + public static void sendFailure(String qrContent, String reason) { + String failureMessage = buildMessage("FAILURE", "签到失败:" + reason); + sendMessageToClient(qrContent, failureMessage); + log.warn("发送失败消息: qrContent={}, reason={}", qrContent, reason); + } + + /** + * 构建标准消息格式 + * + * @param type 消息类型 + * @param content 消息内容 + * @return 格式化后的消息字符串 + */ + private static String buildMessage(String type, String content) { + return JSONUtil.toJsonStr(Map.of( + "type", type, + "content", content, + "timestamp", System.currentTimeMillis() + )); + } + + /** + * 清理超时连接 + */ + private static void cleanupTimeoutConnections() { + LocalDateTime now = LocalDateTime.now(); + qrContentToCreateTime.entrySet().removeIf(entry -> { + LocalDateTime createTime = entry.getValue(); + long secondsDiff = java.time.Duration.between(createTime, now).getSeconds(); + if (secondsDiff > TIMEOUT_SECONDS) { + String qrContent = entry.getKey(); + qrContentToSink.remove(qrContent); + log.debug("清理超时连接: qrContent={}, 超时时间={}秒", qrContent, secondsDiff); + return true; + } + return false; + }); + } + + /** + * 获取当前连接数 + * + * @return 连接数 + */ + public static int getConnectionCount() { + cleanupTimeoutConnections(); + return qrContentToSink.size(); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/gym-manage-api/gym-checkIn/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..e69de29 diff --git a/gym-manage-api/gym-checkIn/src/main/resources/checkIn-config.yml b/gym-manage-api/gym-checkIn/src/main/resources/checkIn-config.yml new file mode 100644 index 0000000..b1eb6d9 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/main/resources/checkIn-config.yml @@ -0,0 +1,10 @@ +# 二维码配置 +qr: + config: + width: 300 # 二维码宽度(像素) + height: 300 # 二维码高度(像素) + margin: 1 # 白边宽度(像素) + format: png # 图片格式:png / jpg + error-correction: L #容错率:L, M, Q, H,如果启用Logo(logo-enabled: true),必须设置为 H + logo-enabled: false # 是否启用Logo(启用时error-correction必须为H) +# logo-path: static/logo.png # Logo图片路径(支持相对路径或绝对路径) \ No newline at end of file diff --git a/gym-manage-api/gym-checkIn/src/test/java/cn/novalon/gym/manage/checkin/CheckInModuleTest.java b/gym-manage-api/gym-checkIn/src/test/java/cn/novalon/gym/manage/checkin/CheckInModuleTest.java new file mode 100644 index 0000000..27fb944 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/test/java/cn/novalon/gym/manage/checkin/CheckInModuleTest.java @@ -0,0 +1,281 @@ +package cn.novalon.gym.manage.checkin; + +import cn.novalon.gym.manage.checkIn.config.QRCodeConfig; +import cn.novalon.gym.manage.checkIn.entity.SignInRecord; +import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository; +import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; +import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl; +import cn.novalon.gym.manage.checkIn.vo.QRCodeVo; +import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO; +import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO; +import cn.novalon.gym.manage.common.constant.RedisKeyConstants; +import cn.novalon.gym.manage.common.util.RedisUtil; +import cn.novalon.gym.manage.member.entity.MemberCard; +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; +import cn.novalon.gym.manage.member.repository.MemberCardRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * 签到模块接口测试类 + * 测试模块三(gym-checkIn)的所有接口 + */ +class CheckInModuleTest { + + @Mock + private QRCodeConfig qrCodeConfig; + + @Mock + private RedisUtil redisUtil; + + @Mock + private MemberCardRecordRepository memberCardRecordRepository; + + @Mock + private MemberCardRepository memberCardRepository; + + @Mock + private SignInRecordRepository signInRecordRepository; + + @Mock + private IGroupCourseBookingService groupCourseBookingService; + + @Mock + private MemberCard mockMemberCard; + + @Mock + private SignInRecord mockSignInRecord; + + @Mock + private MemberCardRecord mockMemberCardRecord; + + private CheckServiceImpl checkService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository, + memberCardRepository, signInRecordRepository, groupCourseBookingService); + + when(mockMemberCard.getId()).thenReturn(1L); + when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD"); + + when(mockSignInRecord.getId()).thenReturn(1L); + when(mockSignInRecord.getMemberId()).thenReturn(1L); + when(mockSignInRecord.getMemberCardId()).thenReturn(1L); + when(mockSignInRecord.getSignInTime()).thenReturn(LocalDateTime.now()); + when(mockSignInRecord.getSignInType()).thenReturn("QR_CODE"); + when(mockSignInRecord.getSignInStatus()).thenReturn("SUCCESS"); + when(mockSignInRecord.getSource()).thenReturn("MINI_PROGRAM"); + + when(mockMemberCardRecord.getMemberCardRecordId()).thenReturn(1L); + when(mockMemberCardRecord.getMemberCardId()).thenReturn(1L); + when(mockMemberCardRecord.getRemainingTimes()).thenReturn(10); + when(mockMemberCardRecord.getRemainingAmount()).thenReturn(100.0); + when(mockMemberCardRecord.getExpireTime()).thenReturn(LocalDateTime.now().plusDays(30)); + when(mockMemberCardRecord.getStatus()).thenReturn(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.ACTIVE); + } + + @Test + @DisplayName("测试1: 获取二维码 - getQRCode") + void testGetQRCode() { + when(memberCardRecordRepository.findActiveCardsByMemberId(1L)) + .thenReturn(Flux.just(mockMemberCardRecord)); + when(redisUtil.setWithExpire(any(String.class), any(Map.class), any(Long.class))) + .thenReturn(Mono.just(true)); + + Mono result = checkService.getQRCode(1L); + + StepVerifier.create(result) + .expectNextMatches(qrCodeVo -> { + org.junit.jupiter.api.Assertions.assertNotNull(qrCodeVo); + org.junit.jupiter.api.Assertions.assertNotNull(qrCodeVo.getQrContent()); + return true; + }) + .verifyComplete(); + } + + @Test + @DisplayName("测试2: 签到 - checkIn") + void testCheckIn() { + Long memberId = 1L; + Map qrData = new HashMap<>(); + qrData.put("qrContent", "test-qr-content"); + qrData.put("memberId", memberId); + qrData.put("memberCardRecordId", 1L); + qrData.put("isUsed", false); + qrData.put("expireTime", System.currentTimeMillis() + 3600000); + + String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(); + + when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData)); + when(memberCardRecordRepository.findById(1L)).thenReturn(Mono.just(mockMemberCardRecord)); + when(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(1L)).thenReturn(Mono.just(mockMemberCard)); + when(signInRecordRepository.save(any(SignInRecord.class))).thenReturn(Mono.just(mockSignInRecord)); + when(redisUtil.set(any(String.class), any(Map.class))).thenReturn(Mono.just(true)); + when(groupCourseBookingService.getBookingsByMemberId(memberId)).thenReturn(Flux.empty()); + when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.empty()); + + Mono result = checkService.checkIn(memberId, "test-qr-content"); + + StepVerifier.create(result) + .expectNextMatches(response -> response.contains("签到成功")) + .verifyComplete(); + } + + @Test + @DisplayName("测试3: 查询签到记录列表 - getSignInRecords") + void testGetSignInRecords() { + when(signInRecordRepository.findByMemberIdAndTimeRange( + eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Flux.just(mockSignInRecord)); + + Flux result = checkService.getSignInRecords(1L, + LocalDate.now().minusDays(30), LocalDate.now()); + + StepVerifier.create(result) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + @DisplayName("测试4: 查询单条签到记录 - getSignInRecordById") + void testGetSignInRecordById() { + when(signInRecordRepository.findById(1L)) + .thenReturn(Mono.just(mockSignInRecord)); + + Mono result = checkService.getSignInRecordById(1L); + + StepVerifier.create(result) + .expectNextMatches(vo -> vo.getId() == 1L) + .verifyComplete(); + } + + @Test + @DisplayName("测试5: 查询签到记录 - 记录不存在") + void testGetSignInRecordById_NotFound() { + when(signInRecordRepository.findById(999L)) + .thenReturn(Mono.empty()); + + Mono result = checkService.getSignInRecordById(999L); + + StepVerifier.create(result) + .verifyComplete(); + } + + @Test + @DisplayName("测试6: 获取签到统计 - getSignInStats") + void testGetSignInStats() { + when(signInRecordRepository.countByMemberIdAndTimeRange( + eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.just(10L)); + when(signInRecordRepository.countSuccessByMemberIdAndTimeRange( + eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.just(8L)); + when(signInRecordRepository.getFirstSignInTime( + eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.just(LocalDateTime.now().minusDays(29))); + when(signInRecordRepository.getLastSignInTime( + eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.just(LocalDateTime.now())); + + Mono result = checkService.getSignInStats(1L, + LocalDate.now().minusDays(30), LocalDate.now()); + + StepVerifier.create(result) + .expectNextMatches(stats -> { + org.junit.jupiter.api.Assertions.assertEquals(10L, stats.getTotalCount()); + org.junit.jupiter.api.Assertions.assertEquals(8L, stats.getSuccessCount()); + return true; + }) + .verifyComplete(); + } + + @Test + @DisplayName("测试7: 获取每日签到统计 - getDailySignInStats") + void testGetDailySignInStats() { + when(signInRecordRepository.countByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.just(50L)); + when(signInRecordRepository.countSuccessByTimeRange( + any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.just(45L)); + when(signInRecordRepository.countDistinctMembersByTimeRange( + any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.just(30L)); + + Mono result = checkService.getDailySignInStats(LocalDate.now()); + + StepVerifier.create(result) + .expectNextMatches(stats -> { + org.junit.jupiter.api.Assertions.assertEquals(50L, stats.getTotalCount()); + org.junit.jupiter.api.Assertions.assertEquals(45L, stats.getSuccessCount()); + org.junit.jupiter.api.Assertions.assertEquals(30L, stats.getUniqueMemberCount()); + return true; + }) + .verifyComplete(); + } + + @Test + @DisplayName("测试8: 导出签到记录 - exportSignInRecords") + void testExportSignInRecords() { + when(signInRecordRepository.findByMemberIdAndTimeRange( + eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Flux.just(mockSignInRecord)); + + Mono result = checkService.exportSignInRecords(1L, + LocalDate.now().minusDays(7), LocalDate.now()); + + StepVerifier.create(result) + .expectNextMatches(bytes -> bytes.length > 0) + .verifyComplete(); + } + + @Test + @DisplayName("测试9: 签到失败 - 二维码无效") + void testCheckIn_QRCodeInvalid() { + Long memberId = 1L; + String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(); + when(redisUtil.get(eq(key))).thenReturn(Mono.just(new HashMap<>())); + when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.empty()); + + Mono result = checkService.checkIn(memberId, "invalid-qr"); + + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + @DisplayName("测试10: 签到失败 - 二维码不存在") + void testCheckIn_QRCodeNotFound() { + Long memberId = 1L; + String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(); + when(redisUtil.get(eq(key))).thenReturn(Mono.empty()); + when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(Mono.empty()); + + Mono result = checkService.checkIn(memberId, "not-exist"); + + StepVerifier.create(result) + .verifyComplete(); + } +} diff --git a/gym-manage-api/gym-checkIn/src/test/resources/application-test.yml b/gym-manage-api/gym-checkIn/src/test/resources/application-test.yml new file mode 100644 index 0000000..ffee846 --- /dev/null +++ b/gym-manage-api/gym-checkIn/src/test/resources/application-test.yml @@ -0,0 +1 @@ +# Test Configuration diff --git a/gym-manage-api/manage-app/pom.xml b/gym-manage-api/manage-app/pom.xml index 11b6479..3bc6fe8 100644 --- a/gym-manage-api/manage-app/pom.xml +++ b/gym-manage-api/manage-app/pom.xml @@ -43,6 +43,11 @@ gym-member ${project.version} + + cn.novalon.gym.manage + gym-checkIn + ${project.version} + org.springframework.boot diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java index b190bfd..7d11ca4 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java @@ -7,10 +7,7 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories; import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; import org.springframework.scheduling.annotation.EnableScheduling; @@ -26,7 +23,8 @@ import java.util.List; "cn.novalon.gym.manage.sys.audit.repository" , "cn.novalon.gym.manage.gymmembercard.dao", "cn.novalon.gym.manage.member.repository", - "cn.novalon.gym.manage.groupcourse.dao" + "cn.novalon.gym.manage.groupcourse.dao", + "cn.novalon.gym.manage.checkIn.repository" }) @EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository") public class ManageApplication { diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java index 76e0b56..935370f 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java @@ -1,6 +1,7 @@ package cn.novalon.gym.manage.app.config; +import cn.novalon.gym.manage.checkIn.handler.CheckInHandler; import cn.novalon.gym.manage.file.handler.SysFileHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler; @@ -66,7 +67,8 @@ public class SystemRouter { MemberCardRecordHandler memberCardRecordHandler, MemberCardTransactionHandler memberCardTransactionHandler, GroupCourseHandler groupCourseHandler, - GroupCourseBookingHandler groupCourseBookingHandler) { + GroupCourseBookingHandler groupCourseBookingHandler, + CheckInHandler checkInHandler) { return route() // ========== 诊断路由 ========== @@ -272,9 +274,24 @@ public class SystemRouter { .POST("/api/groupCourse/book", groupCourseBookingHandler::bookCourse) .POST("/api/groupCourse/booking/{bookingId}/cancel", groupCourseBookingHandler::cancelBooking) .GET("/api/groupCourse/bookings/member/{memberId}", groupCourseBookingHandler::getBookingsByMemberId) - .GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById) .GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId) + .GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById) + // ========= 签到模块路由 ========== + // ===== 签到核心功能 ===== + .POST("/api/checkIn", checkInHandler::checkIn) + .GET("/api/checkIn/qrcode", checkInHandler::getQRCode) + + // ===== 签到记录管理 ===== + .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) .build(); } } diff --git a/gym-manage-api/manage-common/pom.xml b/gym-manage-api/manage-common/pom.xml index 7f51635..e5a81c2 100644 --- a/gym-manage-api/manage-common/pom.xml +++ b/gym-manage-api/manage-common/pom.xml @@ -56,6 +56,10 @@ spring-boot-starter-test test + + org.springframework.data + spring-data-redis + diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/constant/RedisKeyConstants.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/constant/RedisKeyConstants.java new file mode 100644 index 0000000..053180d --- /dev/null +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/constant/RedisKeyConstants.java @@ -0,0 +1,64 @@ +package cn.novalon.gym.manage.common.constant; + +/** + * Redis 缓存 Key 常量类 + * 统一管理项目中所有 Redis 缓存的 key 前缀 + * + * @author auto-generated + * @date 2026-05-30 + */ +public final class RedisKeyConstants { + + private RedisKeyConstants() { + } + + // ==================== 会员模块 ==================== + + /** + * 会员信息缓存 + * 格式:member:info:{memberId} + */ + public static final String MEMBER_INFO = "member:info:"; + + /** + * 会员详情缓存 + * 格式:member:detail:{memberId} + */ + public static final String MEMBER_DETAIL = "member:detail:"; + + /** + * 会员卡类型缓存 + * 格式:member:card:{memberCardId} + */ + public static final String MEMBER_CARD = "member:card:"; + + /** + * 会员卡记录缓存(包含剩余次数/金额) + * 格式:member:card:record:{recordId} + */ + public static final String MEMBER_CARD_RECORD = "member:card:record:"; + + /** + * 会员退款申请缓存 + * 格式:member:refund:{recordId} + */ + public static final String MEMBER_REFUND = "member:refund:"; + + // ==================== 签到模块 ==================== + + /** + * 用户当日二维码缓存 + * 格式:qrcode:user:daily:{userId}:{date} + * 示例:qrcode:user:daily:1:2026-05-30 + */ + public static final String QRCODE_USER_DAILY = "qrcode:user:daily:"; + + // ==================== 微信模块 ==================== + + /** + * 微信 access_token 缓存 + * 格式:wechat:access_token:{appType} + * appType: miniapp(小程序), mp(公众号) + */ + public static final String WECHAT_ACCESS_TOKEN = "wechat:access_token:"; +} \ No newline at end of file diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V6__Create_sign_in_record_table.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V6__Create_sign_in_record_table.sql new file mode 100644 index 0000000..8a99468 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V6__Create_sign_in_record_table.sql @@ -0,0 +1,74 @@ +-- ============================================ +-- 会员到店签到记录表 +-- 版本: V6 +-- 描述: 创建sign_in_record表,用于记录会员签到信息 +-- ============================================ + +-- 创建签到记录表 +CREATE TABLE IF NOT EXISTS sign_in_record ( + id BIGSERIAL PRIMARY KEY, -- 自增主键 + member_id BIGINT NOT NULL, -- 会员ID,关联member表 + member_card_id BIGINT, -- 签到时使用的会员卡ID + sign_in_time TIMESTAMP NOT NULL, -- 签到入场时间 + sign_in_type VARCHAR(20) NOT NULL, -- 签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别 + sign_in_status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', -- 签到状态:SUCCESS-成功,FAILED-失败 + verification_details TEXT, -- JSON格式,存储会员卡验证时的快照数据 + fail_reason VARCHAR(500), -- 失败时的具体原因文案 + operator_id BIGINT, -- 操作人ID(前台人员),自助签到时为NULL + operator_name VARCHAR(100), -- 操作人姓名冗余 + device_info VARCHAR(200), -- 签到设备标识或型号 + ip_address VARCHAR(50), -- 客户端IP地址 + source VARCHAR(20) NOT NULL, -- 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端 + is_delete BOOLEAN DEFAULT FALSE, -- 软删除标识:false-未删除,true-已删除 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 记录创建时间 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- 记录更新时间 +); + +-- 创建索引 +-- 会员ID索引(加速按会员查询签到记录) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_id ON sign_in_record(member_id); + +-- 签到时间索引(加速按时间范围查询) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_time ON sign_in_record(sign_in_time); + +-- 签到状态索引(加速按状态筛选) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_status ON sign_in_record(sign_in_status); + +-- 会员卡ID索引(加速按会员卡查询) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_card_id ON sign_in_record(member_card_id); + +-- 操作人ID索引(加速按操作人查询) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_operator_id ON sign_in_record(operator_id); + +-- 签到来源索引(加速按来源统计) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_source ON sign_in_record(source); + +-- 软删除索引(加速查询未删除的记录) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_is_delete ON sign_in_record(is_delete); + +-- 复合索引:会员ID + 签到时间(加速会员签到历史查询) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_time ON sign_in_record(member_id, sign_in_time); + +-- 复合索引:签到状态 + 签到时间(加速统计数据查询) +CREATE INDEX IF NOT EXISTS idx_sign_in_record_status_time ON sign_in_record(sign_in_status, sign_in_time); + +-- 添加表注释 +COMMENT ON TABLE sign_in_record IS '会员到店签到记录表'; + +-- 添加字段注释 +COMMENT ON COLUMN sign_in_record.id IS '自增主键'; +COMMENT ON COLUMN sign_in_record.member_id IS '会员ID,关联member表'; +COMMENT ON COLUMN sign_in_record.member_card_id IS '签到时使用的会员卡ID'; +COMMENT ON COLUMN sign_in_record.sign_in_time IS '签到入场时间'; +COMMENT ON COLUMN sign_in_record.sign_in_type IS '签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别'; +COMMENT ON COLUMN sign_in_record.sign_in_status IS '签到状态:SUCCESS-成功,FAILED-失败'; +COMMENT ON COLUMN sign_in_record.verification_details IS 'JSON格式,存储会员卡验证时的快照数据'; +COMMENT ON COLUMN sign_in_record.fail_reason IS '失败时的具体原因文案'; +COMMENT ON COLUMN sign_in_record.operator_id IS '操作人ID(前台人员),自助签到时为NULL'; +COMMENT ON COLUMN sign_in_record.operator_name IS '操作人姓名冗余'; +COMMENT ON COLUMN sign_in_record.device_info IS '签到设备标识或型号'; +COMMENT ON COLUMN sign_in_record.ip_address IS '客户端IP地址'; +COMMENT ON COLUMN sign_in_record.source IS '签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端'; +COMMENT ON COLUMN sign_in_record.is_delete IS '软删除标识:false-未删除,true-已删除'; +COMMENT ON COLUMN sign_in_record.created_at IS '记录创建时间'; +COMMENT ON COLUMN sign_in_record.updated_at IS '记录更新时间'; diff --git a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java index ea4b245..54c4a4e 100644 --- a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java +++ b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java @@ -60,7 +60,8 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactorymanage-file gym-member gym-groupCourse + gym-checkIn