完善模块4,4.1基础数据统计

This commit is contained in:
2026-06-09 18:28:39 +08:00
parent a2d6e36c46
commit 50a5da1df9
14 changed files with 768 additions and 51 deletions
+188 -18
View File
@@ -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
+7
View File
@@ -87,6 +87,13 @@
<artifactId>gym-groupCourse</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 签到模块依赖 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-checkIn</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
<build>
@@ -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("数据统计模块启动完成");
}
}
@@ -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();
@@ -151,6 +151,101 @@ public class StatisticsDao {
.one();
}
// ========== 签到数据统计 ==========
public Mono<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Object[]> 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<Object[]> findMemberStatisticsByDateRange(LocalDate startDate, LocalDate endDate) {
@@ -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;
}
}
@@ -29,6 +29,9 @@ public class StatisticsExportDTO {
@Schema(description = "预约统计数据")
private List<BookingStatistics> bookingStatisticsList;
@Schema(description = "签到统计数据")
private List<SignInStatistics> signInStatisticsList;
public StatisticsExportDTO() {
}
@@ -87,4 +90,12 @@ public class StatisticsExportDTO {
public void setBookingStatisticsList(List<BookingStatistics> bookingStatisticsList) {
this.bookingStatisticsList = bookingStatisticsList;
}
public List<SignInStatistics> getSignInStatisticsList() {
return signInStatisticsList;
}
public void setSignInStatisticsList(List<SignInStatistics> signInStatisticsList) {
this.signInStatisticsList = signInStatisticsList;
}
}
@@ -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<ServerResponse> 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<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "获取签到数据统计(按日期范围)", description = "获取指定日期范围内的签到数据统计")
public Mono<ServerResponse> 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<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "获取签到数据统计汇总", description = "获取指定日期范围内的签到数据统计汇总")
public Mono<ServerResponse> 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<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "导出签到数据统计", description = "导出指定日期范围内的签到数据统计为CSV文件")
public Mono<ServerResponse> 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<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
}
@@ -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();
}
}
@@ -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<BookingStatistics> getBookingStatisticsSummary(LocalDate startDate, LocalDate endDate);
// ========== 签到数据统计 ==========
Mono<SignInStatistics> getSignInStatisticsByDate(LocalDate date);
Flux<SignInStatistics> getSignInStatisticsByDateRange(LocalDate startDate, LocalDate endDate);
Mono<SignInStatistics> getSignInStatisticsSummary(LocalDate startDate, LocalDate endDate);
Mono<byte[]> exportSignInStatistics(LocalDate startDate, LocalDate endDate);
Mono<byte[]> exportMemberStatistics(LocalDate startDate, LocalDate endDate);
Mono<byte[]> exportBookingStatistics(LocalDate startDate, LocalDate endDate);
Mono<byte[]> exportAllStatistics(LocalDate startDate, LocalDate endDate);
RedisUtil getRedisUtil();
}
@@ -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<SignInStatistics> 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<SignInStatistics> 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<SignInStatistics> 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<SignInStatistics> 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<byte[]> exportSignInStatistics(LocalDate startDate, LocalDate endDate) {
return getSignInStatisticsByDateRange(startDate, endDate)
.collectList()
.map(this::generateSignInStatisticsCSV);
}
private byte[] generateSignInStatisticsCSV(List<SignInStatistics> statsList) {
return generateSignInStatisticsCSVContent(statsList).getBytes(StandardCharsets.UTF_8);
}
private String generateSignInStatisticsCSVContent(List<SignInStatistics> 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<byte[]> exportMemberStatistics(LocalDate startDate, LocalDate endDate) {
return getMemberStatisticsByDateRange(startDate, endDate)
@@ -252,7 +400,8 @@ public class DataCountServiceImpl implements IDataCountService {
public Mono<byte[]> 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;
}
}
@@ -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) {
+1
View File
@@ -44,6 +44,7 @@
<module>manage-file</module>
<module>gym-member</module>
<module>gym-groupCourse</module>
<module>gym-checkIn</module>
<module>gym-dataCount</module>
</modules>