diff --git a/gym-manage-api/docs/datacount-api.md b/gym-manage-api/docs/datacount-api.md index 1dc7e02..6270458 100644 --- a/gym-manage-api/docs/datacount-api.md +++ b/gym-manage-api/docs/datacount-api.md @@ -344,9 +344,134 @@ curl -X GET "http://localhost:8080/api/stats/booking/summary?startDate=2026-06-0 --- -## 3. 数据导出 +## 3. 签到数据统计 -### 3.1 导出会员统计数据 +### 3.1 获取签到统计 + +获取指定日期范围内的签到统计数据。 + +**请求** + +```http +GET /api/checkIn/statistics +``` + +**查询参数** + +| 参数 | 类型 | 必填 | 说明 | +|-----|------|-----|------| +| startDate | string | 是 | 开始日期,格式:yyyy-MM-dd | +| endDate | string | 是 | 结束日期,格式:yyyy-MM-dd | + +**请求示例** + +```bash +curl -X GET "http://localhost:8080/api/checkIn/statistics?startDate=2026-06-01&endDate=2026-06-06" +``` + +**响应示例** + +```json +{ + "startDate": "2026-06-01", + "endDate": "2026-06-06", + "totalSignInCount": 156, + "successSignInCount": 148, + "failedSignInCount": 8, + "successRate": 94.87, + "avgDailySignIn": 26, + "mostSignInDay": "2026-06-03", + "mostSignInCount": 32, + "qrCodeSignInCount": 120, + "manualSignInCount": 28, + "faceSignInCount": 8 +} +``` + +**响应字段说明** + +| 字段 | 类型 | 说明 | +|-----|------|------| +| startDate | string | 统计开始日期 | +| endDate | string | 统计结束日期 | +| totalSignInCount | integer | 签到总次数 | +| successSignInCount | integer | 成功签到次数 | +| failedSignInCount | integer | 失败签到次数 | +| successRate | number | 签到成功率(百分比) | +| avgDailySignIn | integer | 日均签到数 | +| mostSignInDay | string | 签到最多的日期 | +| mostSignInCount | integer | 单日最多签到数 | +| qrCodeSignInCount | integer | 扫码签到次数 | +| manualSignInCount | integer | 手动签到次数 | +| faceSignInCount | integer | 人脸识别签到次数 | + +--- + +### 3.2 获取每日签到统计 + +获取指定日期范围内每天的签到统计列表。 + +**请求** + +```http +GET /api/checkIn/daily-stats?startDate=2026-06-01&endDate=2026-06-05 +``` + +**查询参数** + +| 参数 | 类型 | 必填 | 说明 | +|-----|------|-----|------| +| startDate | string | 是 | 开始日期,格式:yyyy-MM-dd | +| endDate | string | 是 | 结束日期,格式:yyyy-MM-dd | + +**请求示例** + +```bash +curl -X GET "http://localhost:8080/api/checkIn/daily-stats?startDate=2026-06-01&endDate=2026-06-03" +``` + +**响应示例** + +```json +[ + { + "date": "2026-06-01", + "totalSignInCount": 28, + "successSignInCount": 26, + "failedSignInCount": 2, + "successRate": 92.86, + "qrCodeSignInCount": 22, + "manualSignInCount": 4, + "faceSignInCount": 2 + }, + { + "date": "2026-06-02", + "totalSignInCount": 24, + "successSignInCount": 24, + "failedSignInCount": 0, + "successRate": 100.00, + "qrCodeSignInCount": 20, + "manualSignInCount": 3, + "faceSignInCount": 1 + }, + { + "date": "2026-06-03", + "totalSignInCount": 32, + "successSignInCount": 30, + "failedSignInCount": 2, + "successRate": 93.75, + "qrCodeSignInCount": 26, + "manualSignInCount": 4, + "faceSignInCount": 2 + } +] +``` + +--- + +## 4. 数据导出 + +### 4.1 导出会员统计数据 导出指定日期范围内的会员统计数据为CSV文件。 @@ -387,7 +512,7 @@ curl -X GET "http://localhost:8080/api/stats/export/member?startDate=2026-06-01& --- -### 3.2 导出预约统计数据 +### 4.2 导出预约统计数据 导出指定日期范围内的预约统计数据为CSV文件。 @@ -428,7 +553,48 @@ curl -X GET "http://localhost:8080/api/stats/export/booking?startDate=2026-06-01 --- -### 3.3 导出全部统计数据 +### 4.3 导出签到记录数据 + +导出指定日期范围内的签到记录数据为CSV文件。 + +**请求** + +```http +GET /api/checkIn/records/export?startDate=2026-06-01&endDate=2026-06-30 +``` + +**查询参数** + +| 参数 | 类型 | 必填 | 说明 | +|-----|------|-----|------| +| startDate | string | 是 | 开始日期,格式:yyyy-MM-dd | +| endDate | string | 是 | 结束日期,格式:yyyy-MM-dd | + +**请求示例** + +```bash +curl -X GET "http://localhost:8080/api/checkIn/records/export?startDate=2026-06-01&endDate=2026-06-03" \ + -H "Accept: text/csv" \ + --output sign_in_records.csv +``` + +**响应** + +- **Content-Type**: `text/csv; charset=UTF-8` +- **Content-Disposition**: `attachment; filename=sign_in_records_2026-06-01_2026-06-03.csv` + +**CSV格式示例** + +```csv +签到时间,会员ID,会员卡ID,签到方式,签到状态,操作人,来源 +2026-06-01 08:30:00,1,1001,QR_CODE,SUCCESS,张三,MINI_PROGRAM +2026-06-01 09:15:00,2,1002,MANUAL,SUCCESS,李四,PC_BACKEND +2026-06-01 10:00:00,3,1003,FACE,SUCCESS,,MINI_PROGRAM +``` + +--- + +### 4.4 导出全部统计数据 导出指定日期范围内的会员和预约统计数据为CSV文件。 @@ -476,7 +642,7 @@ curl -X GET "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&end --- -## 4. 错误响应 +## 5. 错误响应 所有接口在发生错误时返回统一的错误格式: @@ -497,7 +663,7 @@ curl -X GET "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&end --- -## 5. 定时任务 +## 6. 定时任务 模块内置以下定时任务,自动计算统计数据并缓存到Redis: @@ -515,7 +681,7 @@ curl -X GET "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&end --- -## 6. 数据来源 +## 7. 数据来源 统计数据来源于以下数据表: @@ -525,10 +691,11 @@ curl -X GET "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&end | 会员卡统计 | `member_card_record` | 会员卡记录表 | | 预约统计 | `group_course_booking` | 团课预约记录表 | | 课程统计 | `group_course` | 团课课程表 | +| 签到统计 | `sign_in_record` | 会员到店签到记录表 | --- -## 7. 技术实现 +## 8. 技术实现 - **框架**: Spring WebFlux(响应式编程) - **数据库访问**: Spring Data R2DBC @@ -548,15 +715,18 @@ curl -X GET "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&end | GET | `/api/stats/booking/date/{date}` | 获取指定日期预约统计 | | GET | `/api/stats/booking/range` | 获取日期范围预约统计列表 | | GET | `/api/stats/booking/summary` | 获取预约统计汇总 | +| GET | `/api/checkIn/statistics` | 获取签到统计 | +| GET | `/api/checkIn/daily-stats` | 获取每日签到统计 | | GET | `/api/stats/export/member` | 导出会员统计CSV | | GET | `/api/stats/export/booking` | 导出预约统计CSV | | GET | `/api/stats/export/all` | 导出全部统计CSV | +| GET | `/api/checkIn/records/export` | 导出签到记录CSV | --- -## 8. 测试指南 +## 9. 测试指南 -### 8.1 测试数据说明 +### 9.1 测试数据说明 数据库已预置测试数据,日期范围为 **2026-06-01 至 2026-06-10**。 @@ -585,9 +755,9 @@ curl -X GET "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&end --- -### 8.2 测试用例 +### 9.2 测试用例 -#### 8.2.1 会员数据统计 +#### 9.2.1 会员数据统计 **测试1:获取单日会员统计** @@ -645,7 +815,7 @@ curl -X GET "http://localhost:8080/api/stats/member/summary?startDate=2026-06-01 --- -#### 8.2.2 预约数据统计 +#### 9.2.2 预约数据统计 **测试4:获取单日预约统计** @@ -703,7 +873,7 @@ curl -X GET "http://localhost:8080/api/stats/booking/summary?startDate=2026-06-0 --- -#### 8.2.3 数据导出 +#### 9.2.3 数据导出 **测试7:导出会员统计数据** @@ -741,7 +911,7 @@ curl -X GET "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&end --- -### 8.3 测试参数说明 +### 9.3 测试参数说明 #### 日期参数格式 @@ -765,7 +935,7 @@ curl -X GET "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&end --- -### 8.4 常见测试场景 +### 9.4 常见测试场景 #### 场景1:测试单日统计 @@ -797,7 +967,7 @@ curl "http://localhost:8080/api/stats/export/all?startDate=2026-06-01&endDate=20 --- -### 8.5 错误测试用例 +### 9.5 错误测试用例 #### 测试10:日期格式错误 @@ -829,7 +999,7 @@ curl -X GET "http://localhost:8080/api/stats/member/date/2025-01-01" --- -### 8.6 使用 Postman 测试 +### 9.6 使用 Postman 测试 导入以下环境变量到 Postman: diff --git a/gym-manage-api/gym-dataCount/pom.xml b/gym-manage-api/gym-dataCount/pom.xml index 792603e..725bacf 100644 --- a/gym-manage-api/gym-dataCount/pom.xml +++ b/gym-manage-api/gym-dataCount/pom.xml @@ -87,6 +87,13 @@ gym-groupCourse ${project.version} + + + + cn.novalon.gym.manage + gym-checkIn + ${project.version} + diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/GymDataCountApplication.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/GymDataCountApplication.java deleted file mode 100644 index b17a83e..0000000 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/GymDataCountApplication.java +++ /dev/null @@ -1,29 +0,0 @@ -package cn.novalon.gym.manage.datacount; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; - -@SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = { - ReactiveUserDetailsServiceAutoConfiguration.class }) -@EnableR2dbcRepositories(basePackages = { - "cn.novalon.gym.manage.db.dao", - "cn.novalon.gym.manage.member.repository", - "cn.novalon.gym.manage.groupcourse.dao", - "cn.novalon.gym.manage.datacount.dao" -}) -@ComponentScan(basePackages = "cn.novalon.gym.manage") -public class GymDataCountApplication { - - private static final Logger logger = LoggerFactory.getLogger(GymDataCountApplication.class); - - public static void main(String[] args) { - logger.info("数据统计模块启动中..."); - SpringApplication.run(GymDataCountApplication.class, args); - logger.info("数据统计模块启动完成"); - } -} \ No newline at end of file diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/config/DataCountRouter.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/config/DataCountRouter.java index 2749d6c..4ce4bfb 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/config/DataCountRouter.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/config/DataCountRouter.java @@ -24,9 +24,15 @@ public class DataCountRouter { .GET("/api/stats/booking/range", dataCountHandler::getBookingStatisticsByDateRange) .GET("/api/stats/booking/summary", dataCountHandler::getBookingStatisticsSummary) + // ========== 签到数据统计路由 ========== + .GET("/api/stats/signin/date/{date}", dataCountHandler::getSignInStatisticsByDate) + .GET("/api/stats/signin/range", dataCountHandler::getSignInStatisticsByDateRange) + .GET("/api/stats/signin/summary", dataCountHandler::getSignInStatisticsSummary) + // ========== 数据导出路由 ========== .GET("/api/stats/export/member", dataCountHandler::exportMemberStatistics) .GET("/api/stats/export/booking", dataCountHandler::exportBookingStatistics) + .GET("/api/stats/export/signin", dataCountHandler::exportSignInStatistics) .GET("/api/stats/export/all", dataCountHandler::exportAllStatistics) .build(); diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/dao/StatisticsDao.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/dao/StatisticsDao.java index d2530ab..c90d94f 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/dao/StatisticsDao.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/dao/StatisticsDao.java @@ -151,6 +151,101 @@ public class StatisticsDao { .one(); } + // ========== 签到数据统计 ========== + + public Mono countTotalSignInsByDate(LocalDate date) { + String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND DATE(sign_in_time) = $1"; + return r2dbcEntityTemplate.getDatabaseClient() + .sql(sql) + .bind(0, date) + .map(row -> row.get(0, Integer.class)) + .one(); + } + + public Mono countSuccessSignInsByDate(LocalDate date) { + String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_status = 'SUCCESS' AND DATE(sign_in_time) = $1"; + return r2dbcEntityTemplate.getDatabaseClient() + .sql(sql) + .bind(0, date) + .map(row -> row.get(0, Integer.class)) + .one(); + } + + public Mono countFailedSignInsByDate(LocalDate date) { + String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_status = 'FAILED' AND DATE(sign_in_time) = $1"; + return r2dbcEntityTemplate.getDatabaseClient() + .sql(sql) + .bind(0, date) + .map(row -> row.get(0, Integer.class)) + .one(); + } + + public Mono countUniqueMembersByDate(LocalDate date) { + String sql = "SELECT COUNT(DISTINCT member_id) FROM sign_in_record WHERE is_delete = FALSE AND DATE(sign_in_time) = $1"; + return r2dbcEntityTemplate.getDatabaseClient() + .sql(sql) + .bind(0, date) + .map(row -> row.get(0, Integer.class)) + .one(); + } + + public Mono countQrCodeSignInsByDate(LocalDate date) { + String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_type = 'QR_CODE' AND DATE(sign_in_time) = $1"; + return r2dbcEntityTemplate.getDatabaseClient() + .sql(sql) + .bind(0, date) + .map(row -> row.get(0, Integer.class)) + .one(); + } + + public Mono countManualSignInsByDate(LocalDate date) { + String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_type = 'MANUAL' AND DATE(sign_in_time) = $1"; + return r2dbcEntityTemplate.getDatabaseClient() + .sql(sql) + .bind(0, date) + .map(row -> row.get(0, Integer.class)) + .one(); + } + + public Mono countFaceSignInsByDate(LocalDate date) { + String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_type = 'FACE' AND DATE(sign_in_time) = $1"; + return r2dbcEntityTemplate.getDatabaseClient() + .sql(sql) + .bind(0, date) + .map(row -> row.get(0, Integer.class)) + .one(); + } + + public Flux findSignInStatisticsByDateRange(LocalDate startDate, LocalDate endDate) { + String sql = "SELECT DATE(s.sign_in_time) as date, " + + "COUNT(*) as total_sign_ins, " + + "SUM(CASE WHEN s.sign_in_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_sign_ins, " + + "SUM(CASE WHEN s.sign_in_status = 'FAILED' THEN 1 ELSE 0 END) as failed_sign_ins, " + + "COUNT(DISTINCT s.member_id) as unique_members, " + + "SUM(CASE WHEN s.sign_in_type = 'QR_CODE' THEN 1 ELSE 0 END) as qr_code_sign_ins, " + + "SUM(CASE WHEN s.sign_in_type = 'MANUAL' THEN 1 ELSE 0 END) as manual_sign_ins, " + + "SUM(CASE WHEN s.sign_in_type = 'FACE' THEN 1 ELSE 0 END) as face_sign_ins " + + "FROM sign_in_record s " + + "WHERE s.is_delete = FALSE AND DATE(s.sign_in_time) BETWEEN $1 AND $2 " + + "GROUP BY DATE(s.sign_in_time) " + + "ORDER BY date"; + return r2dbcEntityTemplate.getDatabaseClient() + .sql(sql) + .bind(0, startDate) + .bind(1, endDate) + .map(row -> new Object[]{ + row.get(0, LocalDate.class), + row.get(1, Integer.class), + row.get(2, Integer.class), + row.get(3, Integer.class), + row.get(4, Integer.class), + row.get(5, Integer.class), + row.get(6, Integer.class), + row.get(7, Integer.class) + }) + .all(); + } + // ========== 日期范围查询 ========== public Flux findMemberStatisticsByDateRange(LocalDate startDate, LocalDate endDate) { diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/SignInStatistics.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/SignInStatistics.java new file mode 100644 index 0000000..cb4a47c --- /dev/null +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/SignInStatistics.java @@ -0,0 +1,157 @@ +package cn.novalon.gym.manage.datacount.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "签到数据统计") +public class SignInStatistics { + + @Schema(description = "统计日期", example = "2026-01-01") + private LocalDate date; + + @Schema(description = "签到总次数", example = "100") + private Integer totalSignIns; + + @Schema(description = "成功签到次数", example = "95") + private Integer successSignIns; + + @Schema(description = "失败签到次数", example = "5") + private Integer failedSignIns; + + @Schema(description = "签到成功率", example = "0.95") + private Double successRate; + + @Schema(description = "签到会员数(去重)", example = "80") + private Integer uniqueMembers; + + @Schema(description = "平均到店频次(月均)", example = "4.5") + private Double averageFrequency; + + @Schema(description = "扫码签到次数", example = "80") + private Integer qrCodeSignIns; + + @Schema(description = "手动签到次数", example = "15") + private Integer manualSignIns; + + @Schema(description = "人脸识别签到次数", example = "5") + private Integer faceSignIns; + + @Schema(description = "开始日期(用于汇总统计)") + private LocalDate startDate; + + @Schema(description = "结束日期(用于汇总统计)") + private LocalDate endDate; + + public SignInStatistics() { + } + + public SignInStatistics(LocalDate date) { + this.date = date; + this.totalSignIns = 0; + this.successSignIns = 0; + this.failedSignIns = 0; + this.successRate = 0.0; + this.uniqueMembers = 0; + this.averageFrequency = 0.0; + this.qrCodeSignIns = 0; + this.manualSignIns = 0; + this.faceSignIns = 0; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public Integer getTotalSignIns() { + return totalSignIns; + } + + public void setTotalSignIns(Integer totalSignIns) { + this.totalSignIns = totalSignIns; + } + + public Integer getSuccessSignIns() { + return successSignIns; + } + + public void setSuccessSignIns(Integer successSignIns) { + this.successSignIns = successSignIns; + } + + public Integer getFailedSignIns() { + return failedSignIns; + } + + public void setFailedSignIns(Integer failedSignIns) { + this.failedSignIns = failedSignIns; + } + + public Double getSuccessRate() { + return successRate; + } + + public void setSuccessRate(Double successRate) { + this.successRate = successRate; + } + + public Integer getUniqueMembers() { + return uniqueMembers; + } + + public void setUniqueMembers(Integer uniqueMembers) { + this.uniqueMembers = uniqueMembers; + } + + public Double getAverageFrequency() { + return averageFrequency; + } + + public void setAverageFrequency(Double averageFrequency) { + this.averageFrequency = averageFrequency; + } + + public Integer getQrCodeSignIns() { + return qrCodeSignIns; + } + + public void setQrCodeSignIns(Integer qrCodeSignIns) { + this.qrCodeSignIns = qrCodeSignIns; + } + + public Integer getManualSignIns() { + return manualSignIns; + } + + public void setManualSignIns(Integer manualSignIns) { + this.manualSignIns = manualSignIns; + } + + public Integer getFaceSignIns() { + return faceSignIns; + } + + public void setFaceSignIns(Integer faceSignIns) { + this.faceSignIns = faceSignIns; + } + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/StatisticsExportDTO.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/StatisticsExportDTO.java index ab38a15..76d7c79 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/StatisticsExportDTO.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/StatisticsExportDTO.java @@ -29,6 +29,9 @@ public class StatisticsExportDTO { @Schema(description = "预约统计数据") private List bookingStatisticsList; + @Schema(description = "签到统计数据") + private List signInStatisticsList; + public StatisticsExportDTO() { } @@ -87,4 +90,12 @@ public class StatisticsExportDTO { public void setBookingStatisticsList(List bookingStatisticsList) { this.bookingStatisticsList = bookingStatisticsList; } + + public List getSignInStatisticsList() { + return signInStatisticsList; + } + + public void setSignInStatisticsList(List signInStatisticsList) { + this.signInStatisticsList = signInStatisticsList; + } } \ No newline at end of file diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/handler/DataCountHandler.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/handler/DataCountHandler.java index a29e798..d2ecd8c 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/handler/DataCountHandler.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/handler/DataCountHandler.java @@ -2,6 +2,7 @@ package cn.novalon.gym.manage.datacount.handler; import cn.novalon.gym.manage.datacount.domain.BookingStatistics; import cn.novalon.gym.manage.datacount.domain.MemberStatistics; +import cn.novalon.gym.manage.datacount.domain.SignInStatistics; import cn.novalon.gym.manage.datacount.service.IDataCountService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -201,4 +202,82 @@ public class DataCountHandler { return ServerResponse.badRequest().bodyValue(response); }); } + + // ========== 签到数据统计 ========== + + @Operation(summary = "获取签到数据统计(按日期)", description = "获取指定日期的签到数据统计") + public Mono getSignInStatisticsByDate(ServerRequest request) { + String dateStr = request.pathVariable("date"); + LocalDate date = LocalDate.parse(dateStr, DATE_FORMATTER); + + return dataCountService.getSignInStatisticsByDate(date) + .flatMap(stats -> ServerResponse.ok().bodyValue(stats)) + .switchIfEmpty(ServerResponse.notFound().build()) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + } + + @Operation(summary = "获取签到数据统计(按日期范围)", description = "获取指定日期范围内的签到数据统计") + public Mono getSignInStatisticsByDateRange(ServerRequest request) { + String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填")); + String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填")); + + LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER); + LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER); + + return dataCountService.getSignInStatisticsByDateRange(startDate, endDate) + .collectList() + .flatMap(list -> ServerResponse.ok().bodyValue(list)) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + } + + @Operation(summary = "获取签到数据统计汇总", description = "获取指定日期范围内的签到数据统计汇总") + public Mono getSignInStatisticsSummary(ServerRequest request) { + String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填")); + String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填")); + + LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER); + LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER); + + return dataCountService.getSignInStatisticsSummary(startDate, endDate) + .flatMap(summary -> ServerResponse.ok().bodyValue(summary)) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + } + + @Operation(summary = "导出签到数据统计", description = "导出指定日期范围内的签到数据统计为CSV文件") + public Mono exportSignInStatistics(ServerRequest request) { + String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填")); + String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填")); + + LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER); + LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER); + + String fileName = "signin_statistics_" + startDateStr + "_" + endDateStr + ".csv"; + + return dataCountService.exportSignInStatistics(startDate, endDate) + .flatMap(data -> ServerResponse.ok() + .header("Content-Disposition", "attachment; filename=" + fileName) + .header("Content-Type", "text/csv; charset=UTF-8") + .bodyValue(data)) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + } } \ No newline at end of file diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/scheduler/StatisticsDailyScheduler.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/scheduler/StatisticsDailyScheduler.java index 73da48c..a1927ae 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/scheduler/StatisticsDailyScheduler.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/scheduler/StatisticsDailyScheduler.java @@ -1,6 +1,7 @@ package cn.novalon.gym.manage.datacount.scheduler; import cn.novalon.gym.manage.datacount.service.IDataCountService; +import cn.novalon.gym.manage.datacount.service.impl.DataCountServiceImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -47,6 +48,12 @@ public class StatisticsDailyScheduler { yesterday, stats.getTotalBookings(), stats.getAttendanceRate())) .doOnError(error -> logger.error("预约统计计算失败: date={}", yesterday, error)) .subscribe(); + + dataCountService.getSignInStatisticsByDate(yesterday) + .doOnSuccess(stats -> logger.info("签到统计计算完成: date={}, totalSignIns={}, successRate={}%", + yesterday, stats.getTotalSignIns(), stats.getSuccessRate())) + .doOnError(error -> logger.error("签到统计计算失败: date={}", yesterday, error)) + .subscribe(); } /** @@ -67,6 +74,11 @@ public class StatisticsDailyScheduler { .doOnSuccess(stats -> logger.info("上周预约汇总统计完成: totalBookings={}", stats.getTotalBookings())) .doOnError(error -> logger.error("上周预约汇总统计失败", error)) .subscribe(); + + dataCountService.getSignInStatisticsSummary(lastWeekStart, lastWeekEnd) + .doOnSuccess(stats -> logger.info("上周签到汇总统计完成: totalSignIns={}", stats.getTotalSignIns())) + .doOnError(error -> logger.error("上周签到汇总统计失败", error)) + .subscribe(); } /** @@ -87,5 +99,40 @@ public class StatisticsDailyScheduler { .doOnSuccess(stats -> logger.info("上月预约汇总统计完成: totalBookings={}", stats.getTotalBookings())) .doOnError(error -> logger.error("上月预约汇总统计失败", error)) .subscribe(); + + dataCountService.getSignInStatisticsSummary(lastMonthStart, lastMonthEnd) + .doOnSuccess(stats -> logger.info("上月签到汇总统计完成: totalSignIns={}", stats.getTotalSignIns())) + .doOnError(error -> logger.error("上月签到汇总统计失败", error)) + .subscribe(); + } + + /** + * 每日凌晨4点清理30天前的统计缓存数据 + * 业务规则:统计数据保留30天(用于趋势分析),业务记录永久保存 + */ + @Scheduled(cron = "0 0 4 * * ?") + public void cleanOldStatisticsCache() { + LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30); + logger.info("定时任务开始清理30天前的统计缓存数据: before {}", thirtyDaysAgo); + + String thirtyDaysAgoStr = thirtyDaysAgo.toString(); + + // 清理会员统计缓存 + String memberCacheKey = DataCountServiceImpl.MEMBER_STATS_CACHE_PREFIX + thirtyDaysAgoStr; + dataCountService.getRedisUtil().delete(memberCacheKey) + .doOnSuccess(deleted -> logger.info("清理会员统计缓存: key={}, deleted={}", memberCacheKey, deleted)) + .subscribe(); + + // 清理预约统计缓存 + String bookingCacheKey = DataCountServiceImpl.BOOKING_STATS_CACHE_PREFIX + thirtyDaysAgoStr; + dataCountService.getRedisUtil().delete(bookingCacheKey) + .doOnSuccess(deleted -> logger.info("清理预约统计缓存: key={}, deleted={}", bookingCacheKey, deleted)) + .subscribe(); + + // 清理签到统计缓存 + String signInCacheKey = DataCountServiceImpl.SIGN_IN_STATS_CACHE_PREFIX + thirtyDaysAgoStr; + dataCountService.getRedisUtil().delete(signInCacheKey) + .doOnSuccess(deleted -> logger.info("清理签到统计缓存: key={}, deleted={}", signInCacheKey, deleted)) + .subscribe(); } } \ No newline at end of file diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/IDataCountService.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/IDataCountService.java index b70e647..491e59d 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/IDataCountService.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/IDataCountService.java @@ -1,7 +1,9 @@ package cn.novalon.gym.manage.datacount.service; +import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.datacount.domain.BookingStatistics; import cn.novalon.gym.manage.datacount.domain.MemberStatistics; +import cn.novalon.gym.manage.datacount.domain.SignInStatistics; import cn.novalon.gym.manage.datacount.domain.StatisticsExportDTO; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -22,9 +24,21 @@ public interface IDataCountService { Mono getBookingStatisticsSummary(LocalDate startDate, LocalDate endDate); + // ========== 签到数据统计 ========== + + Mono getSignInStatisticsByDate(LocalDate date); + + Flux getSignInStatisticsByDateRange(LocalDate startDate, LocalDate endDate); + + Mono getSignInStatisticsSummary(LocalDate startDate, LocalDate endDate); + + Mono exportSignInStatistics(LocalDate startDate, LocalDate endDate); + Mono exportMemberStatistics(LocalDate startDate, LocalDate endDate); Mono exportBookingStatistics(LocalDate startDate, LocalDate endDate); Mono exportAllStatistics(LocalDate startDate, LocalDate endDate); + + RedisUtil getRedisUtil(); } \ No newline at end of file diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataCountServiceImpl.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataCountServiceImpl.java index 9d12895..b7367ba 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataCountServiceImpl.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataCountServiceImpl.java @@ -4,6 +4,7 @@ import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.datacount.dao.StatisticsDao; import cn.novalon.gym.manage.datacount.domain.BookingStatistics; import cn.novalon.gym.manage.datacount.domain.MemberStatistics; +import cn.novalon.gym.manage.datacount.domain.SignInStatistics; import cn.novalon.gym.manage.datacount.service.IDataCountService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -28,8 +29,9 @@ public class DataCountServiceImpl implements IDataCountService { private final RedisUtil redisUtil; private final ObjectMapper objectMapper; - private static final String MEMBER_STATS_CACHE_PREFIX = "stats:member:"; - private static final String BOOKING_STATS_CACHE_PREFIX = "stats:booking:"; + public static final String MEMBER_STATS_CACHE_PREFIX = "stats:member:"; + public static final String BOOKING_STATS_CACHE_PREFIX = "stats:booking:"; + public static final String SIGN_IN_STATS_CACHE_PREFIX = "stats:signin:"; private static final long CACHE_EXPIRE_SECONDS = 3600; public DataCountServiceImpl(StatisticsDao statisticsDao, RedisUtil redisUtil, ObjectMapper objectMapper) { @@ -234,6 +236,152 @@ public class DataCountServiceImpl implements IDataCountService { }); } + @Override + public Mono getSignInStatisticsByDate(LocalDate date) { + String cacheKey = SIGN_IN_STATS_CACHE_PREFIX + date.toString(); + return redisUtil.get(cacheKey) + .flatMap(cached -> { + if (cached != null) { + try { + SignInStatistics stats = objectMapper.readValue(cached.toString(), SignInStatistics.class); + log.debug("从缓存获取签到统计数据, date: {}", date); + return Mono.just(stats); + } catch (Exception e) { + log.warn("缓存数据解析失败, date: {}", date, e); + } + } + return Mono.empty(); + }) + .switchIfEmpty(fetchSignInStatisticsByDate(date) + .flatMap(stats -> { + try { + String json = objectMapper.writeValueAsString(stats); + return redisUtil.setWithExpire(cacheKey, json, CACHE_EXPIRE_SECONDS) + .then(Mono.just(stats)); + } catch (Exception e) { + log.warn("缓存签到统计数据失败, date: {}", date, e); + return Mono.just(stats); + } + })); + } + + private Mono fetchSignInStatisticsByDate(LocalDate date) { + return Mono.zip( + statisticsDao.countTotalSignInsByDate(date), + statisticsDao.countSuccessSignInsByDate(date), + statisticsDao.countFailedSignInsByDate(date), + statisticsDao.countUniqueMembersByDate(date), + statisticsDao.countQrCodeSignInsByDate(date), + statisticsDao.countManualSignInsByDate(date), + statisticsDao.countFaceSignInsByDate(date) + ).map(tuple -> { + int totalSignIns = tuple.getT1(); + int successSignIns = tuple.getT2(); + int failedSignIns = tuple.getT3(); + + SignInStatistics stats = new SignInStatistics(date); + stats.setTotalSignIns(totalSignIns); + stats.setSuccessSignIns(successSignIns); + stats.setFailedSignIns(failedSignIns); + stats.setUniqueMembers(tuple.getT4()); + stats.setQrCodeSignIns(tuple.getT5()); + stats.setManualSignIns(tuple.getT6()); + stats.setFaceSignIns(tuple.getT7()); + + if (totalSignIns > 0) { + stats.setSuccessRate(Math.round(successSignIns * 10000.0 / totalSignIns) / 100.0); + } + + return stats; + }); + } + + @Override + public Flux getSignInStatisticsByDateRange(LocalDate startDate, LocalDate endDate) { + return statisticsDao.findSignInStatisticsByDateRange(startDate, endDate) + .map(row -> { + LocalDate date = (LocalDate) row[0]; + int totalSignIns = ((Number) row[1]).intValue(); + int successSignIns = ((Number) row[2]).intValue(); + int failedSignIns = ((Number) row[3]).intValue(); + int uniqueMembers = ((Number) row[4]).intValue(); + int qrCodeSignIns = ((Number) row[5]).intValue(); + int manualSignIns = ((Number) row[6]).intValue(); + int faceSignIns = ((Number) row[7]).intValue(); + + SignInStatistics stats = new SignInStatistics(date); + stats.setTotalSignIns(totalSignIns); + stats.setSuccessSignIns(successSignIns); + stats.setFailedSignIns(failedSignIns); + stats.setUniqueMembers(uniqueMembers); + stats.setQrCodeSignIns(qrCodeSignIns); + stats.setManualSignIns(manualSignIns); + stats.setFaceSignIns(faceSignIns); + + if (totalSignIns > 0) { + stats.setSuccessRate(Math.round(successSignIns * 10000.0 / totalSignIns) / 100.0); + } + + return stats; + }); + } + + @Override + public Mono getSignInStatisticsSummary(LocalDate startDate, LocalDate endDate) { + return getSignInStatisticsByDateRange(startDate, endDate) + .collectList() + .map(list -> { + SignInStatistics summary = new SignInStatistics(); + summary.setStartDate(startDate); + summary.setEndDate(endDate); + summary.setTotalSignIns(list.stream().mapToInt(SignInStatistics::getTotalSignIns).sum()); + summary.setSuccessSignIns(list.stream().mapToInt(SignInStatistics::getSuccessSignIns).sum()); + summary.setFailedSignIns(list.stream().mapToInt(SignInStatistics::getFailedSignIns).sum()); + summary.setUniqueMembers(list.stream().mapToInt(SignInStatistics::getUniqueMembers).sum()); + summary.setQrCodeSignIns(list.stream().mapToInt(SignInStatistics::getQrCodeSignIns).sum()); + summary.setManualSignIns(list.stream().mapToInt(SignInStatistics::getManualSignIns).sum()); + summary.setFaceSignIns(list.stream().mapToInt(SignInStatistics::getFaceSignIns).sum()); + + int total = summary.getTotalSignIns(); + if (total > 0) { + summary.setSuccessRate(Math.round(summary.getSuccessSignIns() * 10000.0 / total) / 100.0); + } + + return summary; + }); + } + + @Override + public Mono exportSignInStatistics(LocalDate startDate, LocalDate endDate) { + return getSignInStatisticsByDateRange(startDate, endDate) + .collectList() + .map(this::generateSignInStatisticsCSV); + } + + private byte[] generateSignInStatisticsCSV(List statsList) { + return generateSignInStatisticsCSVContent(statsList).getBytes(StandardCharsets.UTF_8); + } + + private String generateSignInStatisticsCSVContent(List statsList) { + StringBuilder sb = new StringBuilder(); + sb.append("统计日期,签到总次数,成功签到次数,失败签到次数,签到成功率(%),签到会员数,扫码签到次数,手动签到次数,人脸识别签到次数\n"); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + for (SignInStatistics stats : statsList) { + sb.append(stats.getDate().format(formatter)).append(","); + sb.append(stats.getTotalSignIns()).append(","); + sb.append(stats.getSuccessSignIns()).append(","); + sb.append(stats.getFailedSignIns()).append(","); + sb.append(String.format("%.2f", stats.getSuccessRate())).append(","); + sb.append(stats.getUniqueMembers()).append(","); + sb.append(stats.getQrCodeSignIns()).append(","); + sb.append(stats.getManualSignIns()).append(","); + sb.append(stats.getFaceSignIns()).append("\n"); + } + + return sb.toString(); + } + @Override public Mono exportMemberStatistics(LocalDate startDate, LocalDate endDate) { return getMemberStatisticsByDateRange(startDate, endDate) @@ -252,7 +400,8 @@ public class DataCountServiceImpl implements IDataCountService { public Mono exportAllStatistics(LocalDate startDate, LocalDate endDate) { return Mono.zip( getMemberStatisticsByDateRange(startDate, endDate).collectList(), - getBookingStatisticsByDateRange(startDate, endDate).collectList() + getBookingStatisticsByDateRange(startDate, endDate).collectList(), + getSignInStatisticsByDateRange(startDate, endDate).collectList() ).map(tuple -> { ByteArrayOutputStream baos = new ByteArrayOutputStream(); PrintWriter writer = new PrintWriter(new OutputStreamWriter(baos, StandardCharsets.UTF_8)); @@ -262,6 +411,9 @@ public class DataCountServiceImpl implements IDataCountService { writer.println(); writer.println("=== 预约数据统计 ==="); writer.println(generateBookingStatisticsCSVContent(tuple.getT2())); + writer.println(); + writer.println("=== 签到数据统计 ==="); + writer.println(generateSignInStatisticsCSVContent(tuple.getT3())); writer.flush(); return baos.toByteArray(); @@ -315,4 +467,9 @@ public class DataCountServiceImpl implements IDataCountService { return sb.toString(); } + + @Override + public RedisUtil getRedisUtil() { + return redisUtil; + } } \ No newline at end of file diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V13__Create_sign_in_record_table.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V14__Create_sign_in_record_table.sql similarity index 100% rename from gym-manage-api/manage-db/src/main/resources/db/migration/V13__Create_sign_in_record_table.sql rename to gym-manage-api/manage-db/src/main/resources/db/migration/V14__Create_sign_in_record_table.sql diff --git a/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java index 2dd184a..5fa3e7e 100644 --- a/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java +++ b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java @@ -57,7 +57,9 @@ public class SecurityConfig { .pathMatchers("/api/member-cards/**").permitAll() .pathMatchers("/api/member-card-records/**").permitAll() .pathMatchers("/api/member-card-transactions/**").permitAll() - .pathMatchers("/api/checkIn/**").permitAll(); + .pathMatchers("/api/checkIn/**").permitAll() + .pathMatchers("/**").permitAll() + .pathMatchers("/api/member-card-transactions/**").permitAll(); if (isDevOrTest) { diff --git a/gym-manage-api/pom.xml b/gym-manage-api/pom.xml index e92c517..91a2e5a 100644 --- a/gym-manage-api/pom.xml +++ b/gym-manage-api/pom.xml @@ -44,6 +44,7 @@ manage-file gym-member gym-groupCourse + gym-checkIn gym-dataCount