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] =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=AD=BE=E5=88=B0=E6=A8=A1?= =?UTF-8?q?=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 '记录更新时间';