完成签到模块功能
This commit was merged in pull request #16.
This commit is contained in:
@@ -27,6 +27,16 @@
|
||||
<artifactId>manage-db</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-member</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-groupCourse</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
|
||||
+176
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
+131
-7
@@ -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<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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)));
|
||||
}
|
||||
}
|
||||
+101
@@ -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<SignInRecord, Long> {
|
||||
|
||||
/**
|
||||
* 查询会员某天的签到记录
|
||||
*/
|
||||
@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<SignInRecord> 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<SignInRecord> 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<Long> 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<Void> 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<SignInRecord> 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<SignInRecord> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<LocalDateTime> 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<LocalDateTime> getLastSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
}
|
||||
+71
-1
@@ -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<QRCodeVo> getQRCode(Long memberId);
|
||||
|
||||
/**
|
||||
* 扫码签到
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param qrContent 二维码内容
|
||||
* @return 签到结果JSON字符串
|
||||
*/
|
||||
Mono<String> checkIn(Long memberId, String qrContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会员签到记录列表
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return 签到记录列表
|
||||
*/
|
||||
Flux<SignInRecordVO> getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 根据ID查询签到记录
|
||||
*
|
||||
* @param id 签到记录ID
|
||||
* @return 签到记录VO
|
||||
*/
|
||||
Mono<SignInRecordVO> getSignInRecordById(Long id);
|
||||
|
||||
/**
|
||||
* 获取会员签到统计
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return 签到统计VO
|
||||
*/
|
||||
Mono<SignInStatsVO> getSignInStats(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 导出会员签到记录
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return CSV格式的字节数组
|
||||
*/
|
||||
Mono<byte[]> exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 获取每日签到统计
|
||||
*
|
||||
* @param date 日期
|
||||
* @return 签到统计VO
|
||||
*/
|
||||
Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
|
||||
}
|
||||
+387
-45
@@ -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<QRCodeVo> 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<String, Object> 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<String, Object> 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<String> 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<String, Object> 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<String, Object> map;
|
||||
if (cachedObj instanceof Map) {
|
||||
map = (Map<String, Object>) 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<SignInRecord> checkTodayAlreadySignedIn(Long memberId) {
|
||||
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
|
||||
LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX);
|
||||
|
||||
return signInRecordRepository.findByMemberIdAndDate(memberId, startOfDay, endOfDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理签到逻辑
|
||||
*/
|
||||
private Mono<String> processCheckIn(Long memberId, Long memberCardRecordId, Map<String, Object> 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<Void> 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<MemberCardRecord> 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<Void> 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<String, Object> res = new HashMap<>();
|
||||
res.put("message", "签到成功");
|
||||
res.put("dateTime", dateTime.format(DATE_FORMATTER));
|
||||
return JSONUtil.toJsonStr(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找会员的有效会员卡(优先选择有效期最早到期的)
|
||||
*/
|
||||
private Mono<MemberCardRecord> 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<SignInRecordVO> 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<SignInRecordVO> getSignInRecordById(Long id) {
|
||||
return signInRecordRepository.findById(id)
|
||||
.map(this::convertToVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInStatsVO> 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<byte[]> 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<String> 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<SignInStatsVO> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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;
|
||||
}
|
||||
|
||||
+66
@@ -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;
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
+162
-58
@@ -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<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* qrContent -> Sink 映射,用于根据二维码内容找到对应的客户端连接
|
||||
*/
|
||||
private static final Map<String, Sinks.Many<String>> qrContentToSink = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 连接创建时间映射,用于超时清理
|
||||
*/
|
||||
private static final Map<String, LocalDateTime> qrContentToCreateTime = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 超时时间(秒),超过此时间未使用的连接将被清理
|
||||
*/
|
||||
private static final long TIMEOUT_SECONDS = 300;
|
||||
|
||||
@Override
|
||||
public Mono<Void> 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<String> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
|
||||
// 处理接收到的消息
|
||||
Flux<WebSocketMessage> 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<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
+273
-4
@@ -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<QRCodeVo> 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<String, Object> 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<String> 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<SignInRecordVO> 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<SignInRecordVO> 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<SignInRecordVO> 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<SignInStatsVO> 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<SignInStatsVO> 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<byte[]> 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<String> 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<String> result = checkService.checkIn(memberId, "not-exist");
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
// ========= 签到模块路由 ==========
|
||||
// ===== 签到核心功能 =====
|
||||
|
||||
+74
@@ -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 '记录更新时间';
|
||||
Reference in New Issue
Block a user