From 78c80c4b1da6ef849d3e0cf551319e39a3fd3a82 Mon Sep 17 00:00:00 2001
From: future <1360317836@qq.com>
Date: Tue, 2 Jun 2026 09:56:37 +0800
Subject: [PATCH 1/2] =?UTF-8?q?=E7=AD=BE=E5=88=B0=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
gym-manage-api/gym-checkIn/.gitignore | 48 ++++
gym-manage-api/gym-checkIn/pom.xml | 242 ++++++++++++++++++
.../manage/checkIn/config/QRCodeConfig.java | 46 ++++
.../checkIn/config/WebSocketConfig.java | 43 ++++
.../manage/checkIn/constant/QRRedisKey.java | 43 ++++
.../gym/manage/checkIn/dto/QRCodeDto.java | 13 +
.../manage/checkIn/entity/SignInRecord.java | 41 +++
.../checkIn/handler/CheckInHandler.java | 69 +++++
.../checkIn/service/ICheckInService.java | 11 +
.../service/impl/CheckServiceImpl.java | 97 +++++++
.../gym/manage/checkIn/vo/QRCodeVo.java | 17 ++
.../checkIn/websocket/MyWebSocketHandler.java | 98 +++++++
...ot.autoconfigure.AutoConfiguration.imports | 0
.../src/main/resources/checkIn-config.yml | 10 +
.../gym/manage/checkin/CheckInModuleTest.java | 12 +
.../src/test/resources/application-test.yml | 1 +
gym-manage-api/manage-app/pom.xml | 5 +
.../gym/manage/app/config/SystemRouter.java | 19 +-
gym-manage-api/manage-common/pom.xml | 4 +
.../common/constant/RedisKeyConstants.java | 64 +++++
.../filter/JwtAuthenticationFilter.java | 3 +-
.../filter/RbacAuthorizationFilter.java | 5 +-
.../gym/manage/sys/config/SecurityConfig.java | 3 +-
gym-manage-api/pom.xml | 1 +
24 files changed, 890 insertions(+), 5 deletions(-)
create mode 100644 gym-manage-api/gym-checkIn/.gitignore
create mode 100644 gym-manage-api/gym-checkIn/pom.xml
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/config/QRCodeConfig.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/config/WebSocketConfig.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/constant/QRRedisKey.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/dto/QRCodeDto.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/entity/SignInRecord.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/handler/CheckInHandler.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/ICheckInService.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/impl/CheckServiceImpl.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/QRCodeVo.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/websocket/MyWebSocketHandler.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
create mode 100644 gym-manage-api/gym-checkIn/src/main/resources/checkIn-config.yml
create mode 100644 gym-manage-api/gym-checkIn/src/test/java/cn/novalon/gym/manage/checkin/CheckInModuleTest.java
create mode 100644 gym-manage-api/gym-checkIn/src/test/resources/application-test.yml
create mode 100644 gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/constant/RedisKeyConstants.java
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..8e29b7e
--- /dev/null
+++ b/gym-manage-api/gym-checkIn/pom.xml
@@ -0,0 +1,242 @@
+
+
+ 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}
+
+
+ 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..db15196
--- /dev/null
+++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/entity/SignInRecord.java
@@ -0,0 +1,41 @@
+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.relational.core.mapping.Column;
+import org.springframework.data.relational.core.mapping.Table;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Table("sign_in_record")
+public class SignInRecord {
+
+ @Id
+ private Long id;
+
+ // 会员ID
+ @Column("member_id")
+ private Long memberId;
+
+ // 签到日期
+ @Column("sign_in_date")
+ private LocalDate signInDate;
+
+ // 签到时间
+ @Column("sign_in_time")
+ private LocalDateTime signInTime;
+
+ // 创建时间
+ @CreatedDate
+ @Column("created_at")
+ private LocalDateTime createdAt;
+}
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..324a9b9
--- /dev/null
+++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/handler/CheckInHandler.java
@@ -0,0 +1,69 @@
+package cn.novalon.gym.manage.checkIn.handler;
+
+import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
+import cn.novalon.gym.manage.sys.util.AuthUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CheckInHandler {
+
+ private final AuthUtil authUtil;
+ private final CheckServiceImpl checkService;
+
+ /**
+ * 签到
+ *
+ * POST /api/checkIn
+ *
+ */
+ public Mono checkIn(ServerRequest request) {
+
+ Long memberId = 1L;
+// authUtil.getMemberIdOrThrow(request);
+ return request.bodyToMono(Map.class)
+ .flatMap(body -> {
+ String qrContent = (String) body.get("qrContent");
+ log.info("收到签到请求, memberId: {}, qrContent: {}", memberId, qrContent);
+
+ return checkService.checkIn(memberId, qrContent)
+ .flatMap(result -> ServerResponse.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(Map.of("code", 200, "message", "签到成功")));
+ })
+ .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 = 1L;
+// authUtil.getMemberIdOrThrow(request);
+
+ log.info("收到用户{}获取二维码请求", memberId);
+
+ return checkService.getQRCode(memberId)
+ .flatMap(qrCodeVo -> ServerResponse.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(qrCodeVo));
+ }
+}
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..9b37980
--- /dev/null
+++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/ICheckInService.java
@@ -0,0 +1,11 @@
+package cn.novalon.gym.manage.checkIn.service;
+
+import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
+import reactor.core.publisher.Mono;
+
+public interface ICheckInService {
+
+ Mono getQRCode(Long memberId);
+
+ Mono checkIn(Long memberId, String qrContent);
+}
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..fbdd4f0
--- /dev/null
+++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/service/impl/CheckServiceImpl.java
@@ -0,0 +1,97 @@
+package cn.novalon.gym.manage.checkIn.service.impl;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.extra.qrcode.QrCodeUtil;
+import cn.hutool.extra.qrcode.QrConfig;
+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.service.ICheckInService;
+import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
+import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
+import cn.novalon.gym.manage.common.util.RedisUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CheckServiceImpl implements ICheckInService {
+
+ @Autowired
+ private final QRCodeConfig qrCodeConfig;
+
+ private final RedisUtil redisUtil;
+
+ @Override
+ public Mono getQRCode(Long memberId) {
+ log.info("开始查询会员信息");
+ // TODO: 获取会员信息 - 查会员卡有效期/剩余次数,过期返回,先查缓存,缓存不存在则查数据库
+ // if (member有效期过了) throw new RuntimeException("会员有效期已过,拒绝生成二维码");
+ log.info("会员信息查询完成");
+
+ log.info("开始生成二维码");
+ String qrContent = QRRedisKey.generateQrcodeContent();
+ Map redisMap = new HashMap<>();
+ redisMap.put("qrContent", qrContent);
+ redisMap.put("isUsed", false);
+
+ 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,qrCodeConfig.getWidth(),qrCodeConfig.getHeight());
+ }));
+ }
+
+ @Override
+ public Mono checkIn(Long memberId, String qrContent) {
+ String key = RedisKeyConstants.QRCODE_USER_DAILY+memberId+LocalDate.now();
+
+ return redisUtil.get(key)
+ .flatMap(cachedQrContent -> {
+ if (cachedQrContent != null) {
+ // 匹配成功,执行签到逻辑
+ Map map = JSONUtil.parseObj(cachedQrContent);
+ if(map.get("qrContent").equals(qrContent)){
+ if((boolean)map.get("isUsed")){
+ log.error("重复签到");
+ throw new RuntimeException("您已经在"+map.get("checkInTime")+"完成签到,请勿重复签到");
+ }
+ log.info("二维码匹配成功,memberId: {}", memberId);
+ // TODO查会员卡缓存,按照卡有效期进行扣减次数,没有缓存查数据库
+ map.put("isUsed", true);
+ map.put("checkInTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+
+ return redisUtil.set(key,map).
+ then(Mono.just("签到成功"));
+ }
+ }
+ throw new RuntimeException("二维码无效");
+ })
+ .switchIfEmpty(Mono.error(new RuntimeException("二维码已过期或不存在")));
+ }
+
+ 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);
+ }
+}
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..13e329e
--- /dev/null
+++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/QRCodeVo.java
@@ -0,0 +1,17 @@
+package cn.novalon.gym.manage.checkIn.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class QRCodeVo {
+
+ private String qrCodeBase64;
+
+ private boolean isUsed;
+
+ private Integer width;
+
+ private Integer height;
+}
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..c786050
--- /dev/null
+++ b/gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/websocket/MyWebSocketHandler.java
@@ -0,0 +1,98 @@
+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.WebSocketMessage;
+import org.springframework.web.reactive.socket.WebSocketSession;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Slf4j
+@Component
+public class MyWebSocketHandler implements WebSocketHandler {
+
+ // 存储所有连接
+ private static final Map sessions = new ConcurrentHashMap<>();
+
+ @Override
+ public Mono handle(WebSocketSession session) {
+ String sessionId = session.getId();
+
+ // 连接建立
+ sessions.put(sessionId, session);
+ log.info("WebSocket 连接建立,sessionId:{},当前连接数:{}", sessionId, sessions.size());
+
+ // 处理接收到的消息
+ Flux output = session.receive()
+ .doOnNext(message -> {
+ String payload = message.getPayloadAsText();
+ log.info("收到消息:{}", payload);
+ })
+ .map(message -> {
+ String payload = message.getPayloadAsText();
+ String response = processMessage(payload, sessionId);
+ return session.textMessage(response);
+ });
+
+ // 连接关闭时清理
+ return session.send(output)
+ .doFinally(signalType -> {
+ sessions.remove(sessionId);
+ log.info("WebSocket 连接关闭,sessionId:{},剩余连接数:{}", sessionId, sessions.size());
+ });
+ }
+
+ /**
+ * 处理消息逻辑
+ */
+ private String processMessage(String message, String sessionId) {
+ try {
+ // 解析 QRCodeDto
+ QRCodeDto qrCodeDto = JSONUtil.toBean(message, QRCodeDto.class);
+
+ String response;
+
+ // 判断二维码是否有效
+ if (qrCodeDto.getQrContent() != null
+ && !qrCodeDto.getQrContent().isEmpty()
+ && !qrCodeDto.isUsed()) {
+ // 有效:qrContent 有值且 isUsed 为 false
+ response = "正在进行签到";
+
+ // 可选:将二维码标记为已使用(需要调用后端服务)
+ // checkInService.handleCheckIn(qrCodeDto.getQrContent());
+
+ log.info("二维码有效,sessionId:{},qrContent:{}", sessionId, qrCodeDto.getQrContent());
+ } else {
+ // 无效:qrContent 为空 或 isUsed 为 true
+ String reason = "";
+ if (qrCodeDto.getQrContent() == null || qrCodeDto.getQrContent().isEmpty()) {
+ reason = "二维码内容为空";
+ } else if (qrCodeDto.isUsed()) {
+ reason = "二维码已被使用";
+ }
+ response = "二维码无效:" + reason;
+ log.warn("二维码无效,sessionId:{},原因:{}", sessionId, reason);
+ }
+
+ return response;
+
+ } catch (Exception e) {
+ log.error("解析消息失败,sessionId:{}", sessionId, e);
+ return "消息格式错误";
+ }
+ }
+
+ /**
+ * 获取当前在线连接数
+ */
+ public static int getOnlineCount() {
+ return sessions.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..bed8bb0
--- /dev/null
+++ b/gym-manage-api/gym-checkIn/src/test/java/cn/novalon/gym/manage/checkin/CheckInModuleTest.java
@@ -0,0 +1,12 @@
+package cn.novalon.gym.manage.checkin;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+public class CheckInModuleTest {
+
+ @Test
+ public void contextLoads() {
+ }
+}
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/config/SystemRouter.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java
index 76e0b56..ad7b8d4 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()
// ========== 诊断路由 ==========
@@ -275,6 +277,21 @@ public class SystemRouter {
.GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById)
.GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId)
+ // ========= 签到模块路由 ==========
+ // ===== 签到核心功能 =====
+ .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-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
--
2.52.0
From 223a427614e8973ba07a9bb97f26d2ae4225bdd6 Mon Sep 17 00:00:00 2001
From: future <1360317836@qq.com>
Date: Tue, 9 Jun 2026 09:30:21 +0800
Subject: [PATCH 2/2] =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=AD=BE=E5=88=B0?=
=?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
gym-manage-api/gym-checkIn/pom.xml | 10 +
.../manage/checkIn/entity/SignInRecord.java | 184 +++++++-
.../checkIn/handler/CheckInHandler.java | 138 +++++-
.../repository/SignInRecordRepository.java | 101 ++++
.../checkIn/service/ICheckInService.java | 72 ++-
.../service/impl/CheckServiceImpl.java | 432 ++++++++++++++++--
.../gym/manage/checkIn/vo/QRCodeVo.java | 6 +
.../gym/manage/checkIn/vo/SignInRecordVO.java | 66 +++
.../gym/manage/checkIn/vo/SignInStatsVO.java | 62 +++
.../checkIn/websocket/MyWebSocketHandler.java | 220 ++++++---
.../gym/manage/checkin/CheckInModuleTest.java | 277 ++++++++++-
.../gym/manage/app/ManageApplication.java | 6 +-
.../gym/manage/app/config/SystemRouter.java | 2 +-
.../V6__Create_sign_in_record_table.sql | 74 +++
14 files changed, 1522 insertions(+), 128 deletions(-)
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/repository/SignInRecordRepository.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/SignInRecordVO.java
create mode 100644 gym-manage-api/gym-checkIn/src/main/java/cn/novalon/gym/manage/checkIn/vo/SignInStatsVO.java
create mode 100644 gym-manage-api/manage-db/src/main/resources/db/migration/V6__Create_sign_in_record_table.sql
diff --git a/gym-manage-api/gym-checkIn/pom.xml b/gym-manage-api/gym-checkIn/pom.xml
index 8e29b7e..c23489b 100644
--- a/gym-manage-api/gym-checkIn/pom.xml
+++ b/gym-manage-api/gym-checkIn/pom.xml
@@ -27,6 +27,16 @@
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
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
index db15196..4946012 100644
--- 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
@@ -6,12 +6,18 @@ 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.LocalDate;
import java.time.LocalDateTime;
+/**
+ * 会员到店签到记录实体
+ *
+ * @author 付嘉
+ * @date 2026-06-08
+ */
@Data
@Builder
@NoArgsConstructor
@@ -19,23 +25,185 @@ import java.time.LocalDateTime;
@Table("sign_in_record")
public class SignInRecord {
+ /**
+ * 自增主键
+ */
@Id
private Long id;
- // 会员ID
+ /**
+ * 会员ID,关联member表
+ */
@Column("member_id")
private Long memberId;
- // 签到日期
- @Column("sign_in_date")
- private LocalDate signInDate;
+ /**
+ * 签到时使用的会员卡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
index 324a9b9..ceae33f 100644
--- 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
@@ -1,9 +1,11 @@
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;
@@ -11,6 +13,8 @@ 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
@@ -21,6 +25,8 @@ public class CheckInHandler {
private final AuthUtil authUtil;
private final CheckServiceImpl checkService;
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
/**
* 签到
*
@@ -29,17 +35,17 @@ public class CheckInHandler {
*/
public Mono checkIn(ServerRequest request) {
- Long memberId = 1L;
-// authUtil.getMemberIdOrThrow(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(Map.of("code", 200, "message", "签到成功")));
+ .bodyValue(result));
})
.onErrorResume(e -> {
log.error("签到失败", e);
@@ -56,8 +62,7 @@ public class CheckInHandler {
*/
public Mono getQRCode(ServerRequest request) {
- Long memberId = 1L;
-// authUtil.getMemberIdOrThrow(request);
+ Long memberId = authUtil.getMemberIdOrThrow(request);
log.info("收到用户{}获取二维码请求", memberId);
@@ -66,4 +71,123 @@ public class CheckInHandler {
.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
index 9b37980..5b4894f 100644
--- 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
@@ -1,11 +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
index fbdd4f0..ad41210 100644
--- 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
@@ -1,26 +1,39 @@
package cn.novalon.gym.manage.checkIn.service.impl;
import cn.hutool.core.bean.BeanUtil;
-import cn.hutool.extra.qrcode.QrCodeUtil;
-import cn.hutool.extra.qrcode.QrConfig;
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.beans.factory.annotation.Autowired;
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
@@ -28,70 +41,399 @@ import java.util.Map;
@RequiredArgsConstructor
public class CheckServiceImpl implements ICheckInService {
- @Autowired
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("开始查询会员信息");
- // TODO: 获取会员信息 - 查会员卡有效期/剩余次数,过期返回,先查缓存,缓存不存在则查数据库
- // if (member有效期过了) throw new RuntimeException("会员有效期已过,拒绝生成二维码");
- log.info("会员信息查询完成");
+ 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());
- log.info("开始生成二维码");
- String qrContent = QRRedisKey.generateQrcodeContent();
- Map redisMap = new HashMap<>();
- redisMap.put("qrContent", qrContent);
- redisMap.put("isUsed", false);
-
- 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,qrCodeConfig.getWidth(),qrCodeConfig.getHeight());
- }));
+ 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();
+ String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
- return redisUtil.get(key)
- .flatMap(cachedQrContent -> {
- if (cachedQrContent != null) {
- // 匹配成功,执行签到逻辑
- Map map = JSONUtil.parseObj(cachedQrContent);
- if(map.get("qrContent").equals(qrContent)){
- if((boolean)map.get("isUsed")){
- log.error("重复签到");
- throw new RuntimeException("您已经在"+map.get("checkInTime")+"完成签到,请勿重复签到");
+ // 先检查当天是否已经签到(从数据库查询,作为重复签到的额外保障)
+ 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);
- // TODO查会员卡缓存,按照卡有效期进行扣减次数,没有缓存查数据库
- map.put("isUsed", true);
- map.put("checkInTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
- return redisUtil.set(key,map).
- then(Mono.just("签到成功"));
+ Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue();
+
+ return processCheckIn(memberId, memberCardRecordId, map, qrContent);
+ } else {
+ MyWebSocketHandler.sendFailure(qrContent, "二维码无效");
+ return Mono.error(new RuntimeException("二维码无效"));
}
}
- throw 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));
})
- .switchIfEmpty(Mono.error(new RuntimeException("二维码已过期或不存在")));
+ .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
index 13e329e..d5dd3e2 100644
--- 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
@@ -3,6 +3,8 @@ package cn.novalon.gym.manage.checkIn.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
+import java.time.LocalDate;
+
@Data
@AllArgsConstructor
public class QRCodeVo {
@@ -11,7 +13,11 @@ public class QRCodeVo {
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
index c786050..6b94625 100644
--- 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
@@ -5,94 +5,198 @@ 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.WebSocketMessage;
import org.springframework.web.reactive.socket.WebSocketSession;
-import reactor.core.publisher.Flux;
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 {
- // 存储所有连接
- private static final Map sessions = new ConcurrentHashMap<>();
+ /**
+ * 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);
- // 连接建立
- sessions.put(sessionId, session);
- log.info("WebSocket 连接建立,sessionId:{},当前连接数:{}", sessionId, sessions.size());
+ // 创建 sink,用于向客户端发送消息
+ Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer();
- // 处理接收到的消息
- Flux output = session.receive()
+ // 订阅接收客户端消息(异步处理)
+ session.receive()
.doOnNext(message -> {
String payload = message.getPayloadAsText();
- log.info("收到消息:{}", payload);
- })
- .map(message -> {
- String payload = message.getPayloadAsText();
- String response = processMessage(payload, sessionId);
- return session.textMessage(response);
- });
+ log.debug("收到消息:sessionId={}, payload={}", sessionId, payload);
- // 连接关闭时清理
- return session.send(output)
- .doFinally(signalType -> {
- sessions.remove(sessionId);
- log.info("WebSocket 连接关闭,sessionId:{},剩余连接数:{}", sessionId, sessions.size());
+ 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 是否发送成功
*/
- private String processMessage(String message, String sessionId) {
- try {
- // 解析 QRCodeDto
- QRCodeDto qrCodeDto = JSONUtil.toBean(message, QRCodeDto.class);
+ public static boolean sendMessageToClient(String qrContent, String message) {
+ // 先清理超时连接
+ cleanupTimeoutConnections();
- String response;
+ Sinks.Many sink = qrContentToSink.get(qrContent);
+ if (sink == null) {
+ log.warn("未找到绑定的连接,qrContent: {}", qrContent);
+ return false;
+ }
- // 判断二维码是否有效
- if (qrCodeDto.getQrContent() != null
- && !qrCodeDto.getQrContent().isEmpty()
- && !qrCodeDto.isUsed()) {
- // 有效:qrContent 有值且 isUsed 为 false
- response = "正在进行签到";
-
- // 可选:将二维码标记为已使用(需要调用后端服务)
- // checkInService.handleCheckIn(qrCodeDto.getQrContent());
-
- log.info("二维码有效,sessionId:{},qrContent:{}", sessionId, qrCodeDto.getQrContent());
- } else {
- // 无效:qrContent 为空 或 isUsed 为 true
- String reason = "";
- if (qrCodeDto.getQrContent() == null || qrCodeDto.getQrContent().isEmpty()) {
- reason = "二维码内容为空";
- } else if (qrCodeDto.isUsed()) {
- reason = "二维码已被使用";
- }
- response = "二维码无效:" + reason;
- log.warn("二维码无效,sessionId:{},原因:{}", sessionId, reason);
- }
-
- return response;
-
- } catch (Exception e) {
- log.error("解析消息失败,sessionId:{}", sessionId, e);
- return "消息格式错误";
+ 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 int getOnlineCount() {
- return sessions.size();
+ 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/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
index bed8bb0..27fb944 100644
--- 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
@@ -1,12 +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.springframework.boot.test.context.SpringBootTest;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
-@SpringBootTest
-public class CheckInModuleTest {
+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
- public void contextLoads() {
+ @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/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 ad7b8d4..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
@@ -274,8 +274,8 @@ 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)
// ========= 签到模块路由 ==========
// ===== 签到核心功能 =====
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 '记录更新时间';
--
2.52.0