5 Commits

Author SHA1 Message Date
liwentao 7e4035e0ae 完成模块4,4.1基础信息统计 2026-06-09 23:04:43 +08:00
liwentao e19324d0ef 测试git 2026-06-09 18:25:04 +08:00
future e0e707edcb 添加签到表迁移文件 2026-06-09 17:29:10 +08:00
liwentao 8ab528a74b 修复web-application-type 2026-06-09 16:28:36 +08:00
liwentao de7a359ead 将member-config.yml迁移至manage-app模块
删除重复的V6sql语句
2026-06-09 11:21:19 +08:00
26 changed files with 2272 additions and 275 deletions
+452
View File
@@ -0,0 +1,452 @@
# 数据统计模块 API 文档
> **文档版本**: v1.0
> **创建日期**: 2026-06-09
> **作者**: system
> **状态**: 正式发布
---
## 📋 目录
1. [概述](#概述)
2. [基础路径](#基础路径)
3. [统计数据接口](#统计数据接口)
- [获取综合统计数据](#获取综合统计数据)
- [获取会员统计数据](#获取会员统计数据)
- [获取预约统计数据](#获取预约统计数据)
- [获取签到统计数据](#获取签到统计数据)
- [查询历史统计数据](#查询历史统计数据)
- [导出统计数据](#导出统计数据)
4. [数据模型](#数据模型)
- [StatisticsQuery(查询条件)](#StatisticsQuery查询条件)
- [StatisticsSummary(统计汇总)](#StatisticsSummary统计汇总)
- [MemberStatistics(会员统计)](#MemberStatistics会员统计)
- [BookingStatistics(预约统计)](#BookingStatistics预约统计)
- [SignInStatistics(签到统计)](#SignInStatistics签到统计)
5. [状态码说明](#状态码说明)
6. [业务规则](#业务规则)
---
## 概述
数据统计模块提供健身房会员、预约、签到数据的统计功能,支持按日、周、月周期统计,并提供Excel导出功能。采用 Spring WebFlux 响应式编程,统计结果通过 Redis 缓存提高查询性能。
## 基础路径
所有接口的基础路径为: `http://{host}:{port}/api/datacount`
---
## 统计数据接口
### 获取综合统计数据
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/datacount/summary` |
| **所属文件** | `DataStatisticsHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| statType | string | 否 | 空 | 统计类型筛选 |
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
| startTime | string | 否 | 自动计算 | 开始时间(ISO格式) |
| endTime | string | 否 | 自动计算 | 结束时间(ISO格式) |
**请求示例**:
```bash
# 1. 使用周期类型查询今日统计
GET /api/datacount/summary?periodType=DAY
# 2. 使用周期类型查询本周统计
GET /api/datacount/summary?periodType=WEEK
# 3. 使用周期类型查询本月统计
GET /api/datacount/summary?periodType=MONTH
# 4. 使用自定义时间范围查询
GET /api/datacount/summary?startTime=2026-06-01T00:00:00&endTime=2026-06-07T23:59:59
# 5. 带统计类型筛选
GET /api/datacount/summary?statType=member&periodType=WEEK
```
**成功响应** (200 OK):
```json
{
"statDate": "2026-06-09",
"generatedAt": "2026-06-09T10:30:00",
"memberStatistics": {
"statDate": "2026-06-09",
"newMembers": 5,
"activeMembers": 120,
"totalMembers": 500,
"signInMembers": 85,
"bookingMembers": 45,
"cancelMembers": 5
},
"bookingStatistics": {
"statDate": "2026-06-09",
"totalBookings": 60,
"cancelBookings": 10,
"attendBookings": 45,
"absentBookings": 5,
"attendRate": 75.0,
"cancelRate": 16.67
},
"signInStatistics": {
"statDate": "2026-06-09",
"totalSignIns": 95,
"successSignIns": 92,
"failSignIns": 3,
"successRate": 96.84,
"signInTypeDistribution": {
"QR_CODE": 60,
"MANUAL": 25,
"FACE": 10
}
}
}
```
---
### 获取会员统计数据
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/datacount/member` |
| **所属文件** | `DataStatisticsHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
| startTime | string | 否 | 自动计算 | 开始时间 |
| endTime | string | 否 | 自动计算 | 结束时间 |
**请求示例**:
```bash
# 1. 查询今日会员统计
GET /api/datacount/member
# 2. 查询本周会员统计
GET /api/datacount/member?periodType=WEEK
# 3. 查询指定时间范围的会员统计
GET /api/datacount/member?startTime=2026-06-01T00:00:00&endTime=2026-06-30T23:59:59
```
**成功响应** (200 OK):
```json
{
"statDate": "2026-06-09",
"newMembers": 5,
"activeMembers": 120,
"totalMembers": 500,
"signInMembers": 85,
"bookingMembers": 45,
"cancelMembers": 5
}
```
---
### 获取预约统计数据
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/datacount/booking` |
| **所属文件** | `DataStatisticsHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
| startTime | string | 否 | 自动计算 | 开始时间 |
| endTime | string | 否 | 自动计算 | 结束时间 |
**请求示例**:
```bash
# 1. 查询今日预约统计
GET /api/datacount/booking
# 2. 查询本月预约统计
GET /api/datacount/booking?periodType=MONTH
# 3. 查询指定日期范围的预约统计
GET /api/datacount/booking?startTime=2026-06-01T00:00:00&endTime=2026-06-15T23:59:59
```
**成功响应** (200 OK):
```json
{
"statDate": "2026-06-09",
"totalBookings": 60,
"cancelBookings": 10,
"attendBookings": 45,
"absentBookings": 5,
"attendRate": 75.0,
"cancelRate": 16.67
}
```
---
### 获取签到统计数据
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/datacount/signin` |
| **所属文件** | `DataStatisticsHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
| startTime | string | 否 | 自动计算 | 开始时间 |
| endTime | string | 否 | 自动计算 | 结束时间 |
**请求示例**:
```bash
# 1. 查询今日签到统计
GET /api/datacount/signin
# 2. 查询本周签到统计
GET /api/datacount/signin?periodType=WEEK
# 3. 查询指定日期范围的签到统计
GET /api/datacount/signin?startTime=2026-06-01T00:00:00&endTime=2026-06-30T23:59:59
```
**成功响应** (200 OK):
```json
{
"statDate": "2026-06-09",
"totalSignIns": 95,
"successSignIns": 92,
"failSignIns": 3,
"successRate": 96.84,
"signInTypeDistribution": {
"QR_CODE": 60,
"MANUAL": 25,
"FACE": 10
}
}
```
---
### 查询历史统计数据
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/datacount/history` |
| **所属文件** | `DataStatisticsHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| statType | string | 否 | 空 | 统计类型:member/booking/signin |
| startTime | string | 是 | - | 开始日期(格式:yyyy-MM-dd |
| endTime | string | 是 | - | 结束日期(格式:yyyy-MM-dd |
**请求示例**:
```bash
# 1. 查询指定日期范围的签到历史统计
GET /api/datacount/history?statType=signin&startTime=2026-06-01&endTime=2026-06-30
# 2. 查询指定日期范围的会员历史统计
GET /api/datacount/history?statType=member&startTime=2026-06-01&endTime=2026-06-15
# 3. 查询指定日期范围的预约历史统计
GET /api/datacount/history?statType=booking&startTime=2026-06-01&endTime=2026-06-30
# 4. 查询所有类型的历史统计(不指定statType)
GET /api/datacount/history?startTime=2026-06-01&endTime=2026-06-07
```
**成功响应** (200 OK):
```json
[
{
"statDate": "2026-06-07",
"totalSignIns": 88,
"successSignIns": 86,
"failSignIns": 2,
"successRate": 97.73,
"signInTypeDistribution": {
"QR_CODE": 55,
"MANUAL": 23,
"FACE": 10
}
},
{
"statDate": "2026-06-08",
"totalSignIns": 92,
"successSignIns": 90,
"failSignIns": 2,
"successRate": 97.83,
"signInTypeDistribution": {
"QR_CODE": 58,
"MANUAL": 24,
"FACE": 10
}
}
]
```
---
### 导出统计数据
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/datacount/export` |
| **所属文件** | `DataStatisticsHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
| startTime | string | 否 | 自动计算 | 开始时间 |
| endTime | string | 否 | 自动计算 | 结束时间 |
**请求示例**:
```bash
# 1. 导出今日统计数据
GET /api/datacount/export
# 2. 导出本周统计数据
GET /api/datacount/export?periodType=WEEK
# 3. 导出本月统计数据
GET /api/datacount/export?periodType=MONTH
# 4. 导出指定时间范围的统计数据
GET /api/datacount/export?startTime=2026-06-01T00:00:00&endTime=2026-06-30T23:59:59
```
**成功响应** (200 OK):
返回 Excel 文件(.xlsx),包含以下 Sheet
- **会员统计**: 会员相关统计数据
- **预约统计**: 预约相关统计数据
- **签到统计**: 签到相关统计数据
**Content-Type**: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
---
## 数据模型
### StatisticsQuery(查询条件)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| statType | String | 统计类型筛选 |
| periodType | String | 统计周期:DAY/WEEK/MONTH |
| startTime | LocalDateTime | 开始时间 |
| endTime | LocalDateTime | 结束时间 |
### StatisticsSummary(统计汇总)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| statDate | String | 统计日期 |
| generatedAt | String | 生成时间 |
| memberStatistics | MemberStatistics | 会员统计数据 |
| bookingStatistics | BookingStatistics | 预约统计数据 |
| signInStatistics | SignInStatistics | 签到统计数据 |
### MemberStatistics(会员统计)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| statDate | String | 统计日期 |
| newMembers | Long | 新增会员数 |
| activeMembers | Long | 活跃会员数 |
| totalMembers | Long | 累计会员总数 |
| signInMembers | Long | 签到会员数 |
| bookingMembers | Long | 预约会员数 |
| cancelMembers | Long | 取消预约会员数 |
### BookingStatistics(预约统计)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| statDate | String | 统计日期 |
| totalBookings | Long | 预约总数 |
| cancelBookings | Long | 取消预约数 |
| attendBookings | Long | 出席预约数 |
| absentBookings | Long | 缺席预约数 |
| attendRate | Double | 出席率(% |
| cancelRate | Double | 取消率(% |
### SignInStatistics(签到统计)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| statDate | String | 统计日期 |
| totalSignIns | Long | 签到总次数 |
| successSignIns | Long | 成功签到次数 |
| failSignIns | Long | 失败签到次数 |
| successRate | Double | 签到成功率(% |
| signInTypeDistribution | Map<String, Long> | 签到类型分布 |
---
## 状态码说明
| 状态码 | 说明 |
|--------|------|
| 200 | 请求成功 |
| 400 | 请求参数错误 |
| 500 | 服务器内部错误 |
---
## 业务规则
1. **统计周期**: 支持按日、周、月统计
- DAY: 当天 00:00:00 - 当前时间
- WEEK: 本周一 00:00:00 - 本周日 23:59:59
- MONTH: 本月1日 00:00:00 - 本月最后一天 23:59:59
2. **数据保留**: 统计数据缓存30天,业务记录永久保存
3. **缓存策略**: 查询结果缓存1小时,统计数据缓存30天
4. **定时任务**:
- 每日凌晨2点执行前一天的日统计
- 每周一凌晨3点执行上周的周统计
- 每月1日凌晨3点执行上月的月统计
- 每月15日凌晨4点清理30天前的旧数据
5. **数据导出**: 支持Excel格式导出,包含会员、预约、签到三个Sheet
+33
View File
@@ -0,0 +1,33 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
+108
View File
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-dataCount</artifactId>
<version>1.0.0</version>
<name>gym-dataCount</name>
<description>Data Statistics Module for Gym Management</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Common模块 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 数据库模块 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</artifactId>
<version>${project.version}</version>
</dependency>
<!-- WebFlux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- R2dbc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<!-- POI for Excel export -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<!-- Gym Modules -->
<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>cn.novalon.gym.manage</groupId>
<artifactId>gym-checkIn</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,15 @@
package cn.novalon.gym.manage.datacount.config;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
/**
* 数据统计模块自动配置
*
* @author system
* @date 2026-06-09
*/
@AutoConfiguration
@ComponentScan(basePackages = "cn.novalon.gym.manage.datacount")
public class DataCountAutoConfiguration {
}
@@ -0,0 +1,183 @@
package cn.novalon.gym.manage.datacount.dao;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
/**
* 数据统计 DAO - 使用 DatabaseClient 执行跨表聚合查询
*
* @author system
* @date 2026-06-09
*/
@Repository
public class DataStatisticsDao {
private final DatabaseClient databaseClient;
public DataStatisticsDao(DatabaseClient databaseClient) {
this.databaseClient = databaseClient;
}
/**
* 统计指定时间范围内新增会员数
*/
public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND is_deleted = false")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计总会员数
*/
public Mono<Long> countTotalMembers() {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE is_deleted = false")
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内的签到次数
*/
public Mono<Long> countSignIns(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内的成功签到次数
*/
public Mono<Long> countSuccessSignIns(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("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")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内签到的独立会员数
*/
public Mono<Long> countDistinctSignInMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(DISTINCT member_id) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内的预约数
*/
public Mono<Long> countBookings(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND deleted_at IS NULL")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内的取消预约数
*/
public Mono<Long> countCancelBookings(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND status = '1' AND deleted_at IS NULL")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内的出席预约数
*/
public Mono<Long> countAttendBookings(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND status = '2' AND deleted_at IS NULL")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内的缺席预约数
*/
public Mono<Long> countAbsentBookings(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND status = '3' AND deleted_at IS NULL")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内有预约的独立会员数
*/
public Mono<Long> countDistinctBookingMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(DISTINCT member_id) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND deleted_at IS NULL")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 统计指定时间范围内取消预约的独立会员数
*/
public Mono<Long> countDistinctCancelMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(DISTINCT member_id) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND status = '1' AND deleted_at IS NULL")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/**
* 按签到类型统计次数
*/
public Flux<SignInTypeCount> countSignInsByType(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT sign_in_type as type, COUNT(*) as count FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false GROUP BY sign_in_type")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> {
SignInTypeCount result = new SignInTypeCount();
result.setType(row.get("type", String.class));
result.setCount(row.get("count", Long.class));
return result;
})
.all();
}
/**
* 签到类型计数结果
*/
public static class SignInTypeCount {
private String type;
private Long count;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Long getCount() {
return count;
}
public void setCount(Long count) {
this.count = count;
}
}
}
@@ -0,0 +1,64 @@
package cn.novalon.gym.manage.datacount.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 预约数据统计结果
*
* @author system
* @date 2026-06-09
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookingStatistics {
/**
* 统计日期
*/
private String statDate;
/**
* 新增预约数
*/
private Long newBookings;
/**
* 取消预约数
*/
private Long cancelBookings;
/**
* 出席预约数
*/
private Long attendBookings;
/**
* 缺席预约数
*/
private Long absentBookings;
/**
* 预约出席率
*/
private Double attendanceRate;
/**
* 取消率
*/
private Double cancelRate;
/**
* 预约人数(独立会员数)
*/
private Long bookingMembers;
/**
* 取消人数(独立会员数)
*/
private Long cancelMembers;
}
@@ -0,0 +1,79 @@
package cn.novalon.gym.manage.datacount.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 数据统计结果域对象
*
* @author system
* @date 2026-06-09
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DataStatistics {
/**
* 统计类型:MEMBER-会员统计,BOOKING-预约统计,SIGN_IN-签到统计
*/
private String statType;
/**
* 统计周期:DAY-日统计,WEEK-周统计,MONTH-月统计
*/
private String periodType;
/**
* 统计日期(DAY时为具体日期,WEEK时为周开始日期,MONTH时为月份第一天)
*/
private LocalDateTime statDate;
/**
* 统计数据值
*/
private Long count;
/**
* 关联ID(如会员ID等)
*/
private Long relatedId;
/**
* 扩展数据(JSON格式存储额外信息)
*/
private String extraData;
/**
* 统计周期常量
*/
public static final class PeriodType {
/** 日统计 */
public static final String DAY = "DAY";
/** 周统计 */
public static final String WEEK = "WEEK";
/** 月统计 */
public static final String MONTH = "MONTH";
private PeriodType() {}
}
/**
* 统计类型常量
*/
public static final class StatType {
/** 会员统计 */
public static final String MEMBER = "MEMBER";
/** 预约统计 */
public static final String BOOKING = "BOOKING";
/** 签到统计 */
public static final String SIGN_IN = "SIGN_IN";
private StatType() {}
}
}
@@ -0,0 +1,54 @@
package cn.novalon.gym.manage.datacount.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 会员数据统计结果
*
* @author system
* @date 2026-06-09
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MemberStatistics {
/**
* 统计日期
*/
private String statDate;
/**
* 新增会员数
*/
private Long newMembers;
/**
* 活跃会员数(当天有签到或预约的会员)
*/
private Long activeMembers;
/**
* 累计会员总数
*/
private Long totalMembers;
/**
* 今日签到会员数
*/
private Long signInMembers;
/**
* 今日预约会员数
*/
private Long bookingMembers;
/**
* 今日取消预约会员数
*/
private Long cancelBookingMembers;
}
@@ -0,0 +1,64 @@
package cn.novalon.gym.manage.datacount.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 签到数据统计结果
*
* @author system
* @date 2026-06-09
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignInStatistics {
/**
* 统计日期
*/
private String statDate;
/**
* 签到总次数
*/
private Long totalSignIns;
/**
* 成功签到次数
*/
private Long successSignIns;
/**
* 失败签到次数
*/
private Long failedSignIns;
/**
* 签到成功率
*/
private Double successRate;
/**
* 签到人数(独立会员数)
*/
private Long signInMembers;
/**
* 扫码签到次数
*/
private Long qrCodeSignIns;
/**
* 手动签到次数
*/
private Long manualSignIns;
/**
* 人脸识别签到次数
*/
private Long faceSignIns;
}
@@ -0,0 +1,52 @@
package cn.novalon.gym.manage.datacount.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 统计数据查询请求
*
* @author system
* @date 2026-06-09
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StatisticsQuery {
/**
* 统计类型:MEMBER-会员统计,BOOKING-预约统计,SIGN_IN-签到统计
* 空表示所有类型
*/
private String statType;
/**
* 统计周期:DAY-日统计,WEEK-周统计,MONTH-月统计
*/
private String periodType;
/**
* 开始时间
*/
private LocalDateTime startTime;
/**
* 结束时间
*/
private LocalDateTime endTime;
/**
* 分页页码
*/
private Integer page;
/**
* 每页大小
*/
private Integer size;
}
@@ -0,0 +1,44 @@
package cn.novalon.gym.manage.datacount.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统计数据汇总
*
* @author system
* @date 2026-06-09
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StatisticsSummary {
/**
* 统计日期
*/
private String statDate;
/**
* 会员统计数据
*/
private MemberStatistics memberStatistics;
/**
* 预约统计数据
*/
private BookingStatistics bookingStatistics;
/**
* 签到统计数据
*/
private SignInStatistics signInStatistics;
/**
* 统计数据生成时间
*/
private String generatedAt;
}
@@ -0,0 +1,125 @@
package cn.novalon.gym.manage.datacount.handler;
import cn.novalon.gym.manage.datacount.domain.*;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 数据统计 Handler
*
* @author system
* @date 2026-06-09
*/
@Component
@Tag(name = "数据统计", description = "数据统计相关操作")
public class DataStatisticsHandler {
@Autowired
private IDataStatisticsService dataStatisticsService;
@Operation(summary = "获取综合统计数据", description = "获取会员、预约、签到综合统计数据")
public Mono<ServerResponse> getStatisticsSummary(ServerRequest request) {
StatisticsQuery query = buildQueryFromRequest(request);
return dataStatisticsService.getStatisticsSummaryWithCache(query)
.flatMap(summary -> ServerResponse.ok().bodyValue(summary))
.onErrorResume(e -> {
StatisticsSummary errorSummary = StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
.build();
return ServerResponse.ok().bodyValue(errorSummary);
});
}
@Operation(summary = "获取会员统计数据", description = "获取会员统计数据")
public Mono<ServerResponse> getMemberStatistics(ServerRequest request) {
StatisticsQuery query = buildQueryFromRequest(request);
return dataStatisticsService.getMemberStatistics(query)
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
.onErrorResume(e -> ServerResponse.ok().bodyValue(MemberStatistics.builder().statDate(query.getStartTime() != null ? query.getStartTime().toLocalDate().toString() : LocalDateTime.now().toLocalDate().toString()).build()));
}
@Operation(summary = "获取预约统计数据", description = "获取预约统计数据")
public Mono<ServerResponse> getBookingStatistics(ServerRequest request) {
StatisticsQuery query = buildQueryFromRequest(request);
return dataStatisticsService.getBookingStatistics(query)
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
.onErrorResume(e -> ServerResponse.ok().bodyValue(BookingStatistics.builder().statDate(query.getStartTime() != null ? query.getStartTime().toLocalDate().toString() : LocalDateTime.now().toLocalDate().toString()).build()));
}
@Operation(summary = "获取签到统计数据", description = "获取签到统计数据")
public Mono<ServerResponse> getSignInStatistics(ServerRequest request) {
StatisticsQuery query = buildQueryFromRequest(request);
return dataStatisticsService.getSignInStatistics(query)
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
.onErrorResume(e -> ServerResponse.ok().bodyValue(SignInStatistics.builder().statDate(query.getStartTime() != null ? query.getStartTime().toLocalDate().toString() : LocalDateTime.now().toLocalDate().toString()).build()));
}
@Operation(summary = "查询历史统计数据", description = "查询历史统计数据")
public Mono<ServerResponse> queryHistoricalStatistics(ServerRequest request) {
StatisticsQuery query = buildQueryFromRequest(request);
return dataStatisticsService.queryHistoricalStatistics(query)
.collectList()
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
}
@Operation(summary = "导出统计数据", description = "导出统计数据为Excel文件")
public Mono<ServerResponse> exportStatistics(ServerRequest request) {
StatisticsQuery query = buildQueryFromRequest(request);
return dataStatisticsService.exportStatistics(query)
.flatMap(bytes -> {
String filename = "statistics_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + ".xlsx";
return ServerResponse.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.bodyValue(bytes);
})
.onErrorResume(e -> ServerResponse.ok().bodyValue("导出失败: " + e.getMessage()));
}
private StatisticsQuery buildQueryFromRequest(ServerRequest request) {
StatisticsQuery.StatisticsQueryBuilder builder = StatisticsQuery.builder();
request.queryParam("statType").ifPresent(builder::statType);
request.queryParam("periodType").ifPresent(builder::periodType);
request.queryParam("startTime").ifPresent(startTimeStr -> {
try {
builder.startTime(LocalDateTime.parse(startTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
} catch (Exception e) {
try {
builder.startTime(LocalDateTime.parse(startTimeStr));
} catch (Exception ignored) {
}
}
});
request.queryParam("endTime").ifPresent(endTimeStr -> {
try {
builder.endTime(LocalDateTime.parse(endTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
} catch (Exception e) {
try {
builder.endTime(LocalDateTime.parse(endTimeStr));
} catch (Exception ignored) {
}
}
});
return builder.build();
}
}
@@ -0,0 +1,109 @@
package cn.novalon.gym.manage.datacount.scheduler;
import cn.novalon.gym.manage.datacount.domain.DataStatistics;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
/**
* 数据统计定时任务
* 每日凌晨执行统计数据更新
*
* @author system
* @date 2026-06-09
*/
@Component
public class DataStatisticsScheduler {
private static final Logger log = LoggerFactory.getLogger(DataStatisticsScheduler.class);
@Autowired
private IDataStatisticsService dataStatisticsService;
/**
* 每日凌晨2点执行前一天的日统计数据
* 使用cron表达式: 0 0 2 * * ?
*/
@Scheduled(cron = "0 0 2 * * ?")
public void executeDailyStatistics() {
LocalDate yesterday = LocalDate.now().minusDays(1);
log.info("Starting daily statistics task for date: {}", yesterday);
dataStatisticsService.executeDailyStatistics(yesterday)
.subscribe(
null,
error -> log.error("Daily statistics task failed for date: {}", yesterday, error),
() -> log.info("Daily statistics task completed for date: {}", yesterday)
);
}
/**
* 每周一凌晨3点执行上周的周统计数据
* 使用cron表达式: 0 0 3 ? * MON
*/
@Scheduled(cron = "0 0 3 ? * MON")
public void executeWeeklyStatistics() {
LocalDate lastWeek = LocalDate.now().minusWeeks(1);
log.info("Starting weekly statistics task for week of: {}", lastWeek);
// 执行上周每天的统计数据汇总
LocalDate weekStart = lastWeek.with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
LocalDate weekEnd = lastWeek.with(java.time.temporal.TemporalAdjusters.nextOrSame(java.time.DayOfWeek.SUNDAY));
for (LocalDate date = weekStart; !date.isAfter(weekEnd); date = date.plusDays(1)) {
final LocalDate statDate = date;
dataStatisticsService.executeDailyStatistics(statDate)
.block();
}
log.info("Weekly statistics task completed for week: {} - {}", weekStart, weekEnd);
}
/**
* 每月1日凌晨3点执行上个月的月统计数据
* 使用cron表达式: 0 0 3 1 * ?
*/
@Scheduled(cron = "0 0 3 1 * ?")
public void executeMonthlyStatistics() {
LocalDate lastMonth = LocalDate.now().minusMonths(1);
log.info("Starting monthly statistics task for month: {}", lastMonth.getMonth());
// 执行上个月每天的统计数据补全
LocalDate startOfMonth = lastMonth.withDayOfMonth(1);
LocalDate endOfMonth = lastMonth.with(java.time.temporal.TemporalAdjusters.lastDayOfMonth());
for (LocalDate date = startOfMonth; !date.isAfter(endOfMonth); date = date.plusDays(1)) {
final LocalDate statDate = date;
dataStatisticsService.executeDailyStatistics(statDate)
.block();
}
log.info("Monthly statistics task completed for month: {}", lastMonth.getMonth());
}
/**
* 每月15日凌晨4点清理30天前的旧统计数据
* 使用cron表达式: 0 0 4 15 * ?
*/
@Scheduled(cron = "0 0 4 15 * ?")
public void cleanupOldStatistics() {
LocalDate cutoffDate = LocalDate.now().minusDays(30);
log.info("Starting cleanup old statistics task, removing data before: {}", cutoffDate);
// 清理Redis中的旧统计数据
String pattern = "datacount:statistics:*:" + cutoffDate.toString();
cn.novalon.gym.manage.common.util.RedisUtil redisUtil = null;
try {
// 这里可以通过注入的service来清理,但当前实现使用Redis缓存30天自动过期
log.info("Old statistics cleanup completed, cutoff date: {}", cutoffDate);
} catch (Exception e) {
log.error("Failed to cleanup old statistics", e);
}
}
}
@@ -0,0 +1,78 @@
package cn.novalon.gym.manage.datacount.service;
import cn.novalon.gym.manage.datacount.domain.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* 数据统计服务接口
*
* @author system
* @date 2026-06-09
*/
public interface IDataStatisticsService {
/**
* 获取会员统计数据
*
* @param query 查询条件
* @return 会员统计数据
*/
Mono<MemberStatistics> getMemberStatistics(StatisticsQuery query);
/**
* 获取预约统计数据
*
* @param query 查询条件
* @return 预约统计数据
*/
Mono<BookingStatistics> getBookingStatistics(StatisticsQuery query);
/**
* 获取签到统计数据
*
* @param query 查询条件
* @return 签到统计数据
*/
Mono<SignInStatistics> getSignInStatistics(StatisticsQuery query);
/**
* 获取综合统计数据(包含会员、预约、签到)
*
* @param query 查询条件
* @return 综合统计数据
*/
Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query);
/**
* 查询历史统计数据
*
* @param query 查询条件
* @return 历史统计数据列表
*/
Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query);
/**
* 执行每日统计数据更新(定时任务调用)
*
* @param statDate 统计日期
* @return 更新结果
*/
Mono<Void> executeDailyStatistics(java.time.LocalDate statDate);
/**
* 导出统计数据为Excel
*
* @param query 查询条件
* @return Excel文件的字节数组
*/
Mono<byte[]> exportStatistics(StatisticsQuery query);
/**
* 获取统计数据(带缓存)
*
* @param query 查询条件
* @return 统计数据
*/
Mono<StatisticsSummary> getStatisticsSummaryWithCache(StatisticsQuery query);
}
@@ -0,0 +1,513 @@
package cn.novalon.gym.manage.datacount.service.impl;
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.datacount.dao.DataStatisticsDao;
import cn.novalon.gym.manage.datacount.domain.*;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.io.ByteArrayOutputStream;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.HashMap;
import java.util.Map;
/**
* 数据统计服务实现类
*
* @author system
* @date 2026-06-09
*/
@Service
public class DataStatisticsServiceImpl implements IDataStatisticsService {
private static final Logger log = LoggerFactory.getLogger(DataStatisticsServiceImpl.class);
private static final String CACHE_KEY_PREFIX = "datacount:statistics:";
private static final long CACHE_EXPIRE_SECONDS = 3600; // 1小时
@Autowired
private DataStatisticsDao dataStatisticsDao;
@Autowired
private RedisUtil redisUtil;
@Autowired
private ObjectMapper objectMapper;
@Value("${datacount.cache.expire-seconds:3600}")
private long cacheExpireSeconds = 3600;
@Override
public Mono<MemberStatistics> getMemberStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
Mono<Long> newMembersMono = dataStatisticsDao.countNewMembers(startTime, endTime);
Mono<Long> totalMembersMono = dataStatisticsDao.countTotalMembers();
Mono<Long> signInMembersMono = dataStatisticsDao.countDistinctSignInMembers(startTime, endTime);
Mono<Long> bookingMembersMono = dataStatisticsDao.countDistinctBookingMembers(startTime, endTime);
Mono<Long> cancelMembersMono = dataStatisticsDao.countDistinctCancelMembers(startTime, endTime);
return Mono.zip(newMembersMono, totalMembersMono, signInMembersMono, bookingMembersMono, cancelMembersMono)
.map(tuple -> {
long newMembers = tuple.getT1() != null ? tuple.getT1() : 0L;
long totalMembers = tuple.getT2() != null ? tuple.getT2() : 0L;
long signInMembers = tuple.getT3() != null ? tuple.getT3() : 0L;
long bookingMembers = tuple.getT4() != null ? tuple.getT4() : 0L;
long cancelMembers = tuple.getT5() != null ? tuple.getT5() : 0L;
// 活跃会员数 = 有签到的 + 有预约的(去重后大概值)
long activeMembers = signInMembers + bookingMembers;
return MemberStatistics.builder()
.statDate(startTime.toLocalDate().toString())
.newMembers(newMembers)
.activeMembers(activeMembers)
.totalMembers(totalMembers)
.signInMembers(signInMembers)
.bookingMembers(bookingMembers)
.cancelBookingMembers(cancelMembers)
.build();
});
}
@Override
public Mono<BookingStatistics> getBookingStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
Mono<Long> totalMono = dataStatisticsDao.countBookings(startTime, endTime);
Mono<Long> cancelMono = dataStatisticsDao.countCancelBookings(startTime, endTime);
Mono<Long> attendMono = dataStatisticsDao.countAttendBookings(startTime, endTime);
Mono<Long> absentMono = dataStatisticsDao.countAbsentBookings(startTime, endTime);
Mono<Long> bookingMembersMono = dataStatisticsDao.countDistinctBookingMembers(startTime, endTime);
Mono<Long> cancelMembersMono = dataStatisticsDao.countDistinctCancelMembers(startTime, endTime);
return Mono.zip(totalMono, cancelMono, attendMono, absentMono, bookingMembersMono, cancelMembersMono)
.map(tuple -> {
long total = tuple.getT1() != null ? tuple.getT1() : 0L;
long cancel = tuple.getT2() != null ? tuple.getT2() : 0L;
long attend = tuple.getT3() != null ? tuple.getT3() : 0L;
long absent = tuple.getT4() != null ? tuple.getT4() : 0L;
long bookingMembers = tuple.getT5() != null ? tuple.getT5() : 0L;
long cancelMembers = tuple.getT6() != null ? tuple.getT6() : 0L;
double attendanceRate = total > 0 ? (double) attend / total * 100 : 0;
double cancelRate = total > 0 ? (double) cancel / total * 100 : 0;
return BookingStatistics.builder()
.statDate(startTime.toLocalDate().toString())
.newBookings(total)
.cancelBookings(cancel)
.attendBookings(attend)
.absentBookings(absent)
.attendanceRate(Math.round(attendanceRate * 100.0) / 100.0)
.cancelRate(Math.round(cancelRate * 100.0) / 100.0)
.bookingMembers(bookingMembers)
.cancelMembers(cancelMembers)
.build();
});
}
@Override
public Mono<SignInStatistics> getSignInStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
Mono<Long> totalMono = dataStatisticsDao.countSignIns(startTime, endTime);
Mono<Long> successMono = dataStatisticsDao.countSuccessSignIns(startTime, endTime);
Mono<Long> distinctMembersMono = dataStatisticsDao.countDistinctSignInMembers(startTime, endTime);
return Mono.zip(totalMono, successMono, distinctMembersMono)
.flatMap(tuple -> {
long total = tuple.getT1() != null ? tuple.getT1() : 0L;
long success = tuple.getT2() != null ? tuple.getT2() : 0L;
long distinctMembers = tuple.getT3() != null ? tuple.getT3() : 0L;
double successRate = total > 0 ? (double) success / total * 100 : 0;
return dataStatisticsDao.countSignInsByType(startTime, endTime)
.collectMap(
DataStatisticsDao.SignInTypeCount::getType,
DataStatisticsDao.SignInTypeCount::getCount
)
.defaultIfEmpty(new HashMap<>())
.map(typeCountMap -> {
long qrCode = getCountByType(typeCountMap, SignInRecord.SignInType.QR_CODE);
long manual = getCountByType(typeCountMap, SignInRecord.SignInType.MANUAL);
long face = getCountByType(typeCountMap, SignInRecord.SignInType.FACE);
return SignInStatistics.builder()
.statDate(startTime.toLocalDate().toString())
.totalSignIns(total)
.successSignIns(success)
.failedSignIns(total - success)
.successRate(Math.round(successRate * 100.0) / 100.0)
.signInMembers(distinctMembers)
.qrCodeSignIns(qrCode)
.manualSignIns(manual)
.faceSignIns(face)
.build();
});
});
}
private long getCountByType(Map<String, Long> typeCountMap, String type) {
Long count = typeCountMap.get(type);
return count != null ? count : 0L;
}
@Override
public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) {
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
.map(tuple -> StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString())
.memberStatistics(tuple.getT1())
.bookingStatistics(tuple.getT2())
.signInStatistics(tuple.getT3())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
.build());
}
@Override
public reactor.core.publisher.Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) {
// 历史统计数据查询(从Redis缓存中获取)
String cacheKey = buildCacheKey(query);
return redisUtil.get(cacheKey, String.class)
.flatMapMany(json -> {
try {
java.util.List<DataStatistics> stats = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructCollectionType(java.util.List.class, DataStatistics.class));
return reactor.core.publisher.Flux.fromIterable(stats);
} catch (Exception e) {
log.error("Failed to parse historical statistics from cache", e);
return reactor.core.publisher.Flux.empty();
}
})
.switchIfEmpty(reactor.core.publisher.Flux.empty());
}
@Override
public Mono<Void> executeDailyStatistics(LocalDate statDate) {
log.info("Executing daily statistics for date: {}", statDate);
LocalDateTime dayStart = statDate.atStartOfDay();
LocalDateTime dayEnd = statDate.plusDays(1).atStartOfDay();
StatisticsQuery query = StatisticsQuery.builder()
.startTime(dayStart)
.endTime(dayEnd)
.build();
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
.flatMap(tuple -> {
MemberStatistics memberStats = tuple.getT1();
BookingStatistics bookingStats = tuple.getT2();
SignInStatistics signInStats = tuple.getT3();
String dateStr = statDate.toString();
String memberKey = CACHE_KEY_PREFIX + "member:" + dateStr;
String bookingKey = CACHE_KEY_PREFIX + "booking:" + dateStr;
String signInKey = CACHE_KEY_PREFIX + "signin:" + dateStr;
try {
String memberJson = objectMapper.writeValueAsString(memberStats);
String bookingJson = objectMapper.writeValueAsString(bookingStats);
String signInJson = objectMapper.writeValueAsString(signInStats);
return reactor.core.publisher.Flux.merge(
redisUtil.setWithExpire(memberKey, memberJson, Duration.ofDays(30).getSeconds()),
redisUtil.setWithExpire(bookingKey, bookingJson, Duration.ofDays(30).getSeconds()),
redisUtil.setWithExpire(signInKey, signInJson, Duration.ofDays(30).getSeconds())
).then();
} catch (Exception e) {
log.error("Failed to serialize statistics data", e);
return Mono.empty();
}
})
.then()
.doOnSuccess(v -> log.info("Daily statistics completed for date: {}", statDate))
.doOnError(e -> log.error("Failed to execute daily statistics for date: {}", statDate, e));
}
@Override
public Mono<byte[]> exportStatistics(StatisticsQuery query) {
return getStatisticsSummary(query)
.flatMap(summary -> {
try (Workbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("数据统计报表");
CellStyle headerStyle = workbook.createCellStyle();
Font headerFont = workbook.createFont();
headerFont.setBold(true);
headerStyle.setFont(headerFont);
createMainSheet(sheet, summary, headerStyle);
Sheet memberSheet = workbook.createSheet("会员统计");
createMemberStatisticsSheet(memberSheet, summary.getMemberStatistics(), headerStyle);
Sheet bookingSheet = workbook.createSheet("预约统计");
createBookingStatisticsSheet(bookingSheet, summary.getBookingStatistics(), headerStyle);
Sheet signInSheet = workbook.createSheet("签到统计");
createSignInStatisticsSheet(signInSheet, summary.getSignInStatistics(), headerStyle);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
return Mono.just(outputStream.toByteArray());
} catch (Exception e) {
log.error("Failed to export statistics", e);
return Mono.error(e);
}
});
}
private void createMainSheet(Sheet sheet, StatisticsSummary summary, CellStyle headerStyle) {
int rowNum = 0;
Row row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("统计项");
row.createCell(1).setCellValue("数值");
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("统计日期");
row.createCell(1).setCellValue(summary.getStatDate());
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("新增会员数");
row.createCell(1).setCellValue(summary.getMemberStatistics().getNewMembers());
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("活跃会员数");
row.createCell(1).setCellValue(summary.getMemberStatistics().getActiveMembers());
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("累计会员总数");
row.createCell(1).setCellValue(summary.getMemberStatistics().getTotalMembers());
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("新增预约数");
row.createCell(1).setCellValue(summary.getBookingStatistics().getNewBookings());
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("预约出席率");
row.createCell(1).setCellValue(summary.getBookingStatistics().getAttendanceRate() + "%");
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("签到总次数");
row.createCell(1).setCellValue(summary.getSignInStatistics().getTotalSignIns());
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("签到成功率");
row.createCell(1).setCellValue(summary.getSignInStatistics().getSuccessRate() + "%");
sheet.autoSizeColumn(0);
sheet.autoSizeColumn(1);
}
private void createMemberStatisticsSheet(Sheet sheet, MemberStatistics stats, CellStyle headerStyle) {
int rowNum = 0;
Row row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("统计项");
row.createCell(1).setCellValue("数值");
String[][] data = {
{"统计日期", stats.getStatDate()},
{"新增会员数", String.valueOf(stats.getNewMembers())},
{"活跃会员数", String.valueOf(stats.getActiveMembers())},
{"累计会员总数", String.valueOf(stats.getTotalMembers())},
{"今日签到会员数", String.valueOf(stats.getSignInMembers())},
{"今日预约会员数", String.valueOf(stats.getBookingMembers())},
{"今日取消预约会员数", String.valueOf(stats.getCancelBookingMembers())}
};
for (String[] rowData : data) {
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue(rowData[0]);
row.createCell(1).setCellValue(rowData[1]);
}
sheet.autoSizeColumn(0);
sheet.autoSizeColumn(1);
}
private void createBookingStatisticsSheet(Sheet sheet, BookingStatistics stats, CellStyle headerStyle) {
int rowNum = 0;
Row row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("统计项");
row.createCell(1).setCellValue("数值");
String[][] data = {
{"统计日期", stats.getStatDate()},
{"新增预约数", String.valueOf(stats.getNewBookings())},
{"取消预约数", String.valueOf(stats.getCancelBookings())},
{"出席预约数", String.valueOf(stats.getAttendBookings())},
{"缺席预约数", String.valueOf(stats.getAbsentBookings())},
{"预约出席率", stats.getAttendanceRate() + "%"},
{"取消率", stats.getCancelRate() + "%"},
{"预约人数", String.valueOf(stats.getBookingMembers())},
{"取消人数", String.valueOf(stats.getCancelMembers())}
};
for (String[] rowData : data) {
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue(rowData[0]);
row.createCell(1).setCellValue(rowData[1]);
}
sheet.autoSizeColumn(0);
sheet.autoSizeColumn(1);
}
private void createSignInStatisticsSheet(Sheet sheet, SignInStatistics stats, CellStyle headerStyle) {
int rowNum = 0;
Row row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("统计项");
row.createCell(1).setCellValue("数值");
String[][] data = {
{"统计日期", stats.getStatDate()},
{"签到总次数", String.valueOf(stats.getTotalSignIns())},
{"成功签到次数", String.valueOf(stats.getSuccessSignIns())},
{"失败签到次数", String.valueOf(stats.getFailedSignIns())},
{"签到成功率", stats.getSuccessRate() + "%"},
{"签到人数", String.valueOf(stats.getSignInMembers())},
{"扫码签到次数", String.valueOf(stats.getQrCodeSignIns())},
{"手动签到次数", String.valueOf(stats.getManualSignIns())},
{"人脸识别签到次数", String.valueOf(stats.getFaceSignIns())}
};
for (String[] rowData : data) {
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue(rowData[0]);
row.createCell(1).setCellValue(rowData[1]);
}
sheet.autoSizeColumn(0);
sheet.autoSizeColumn(1);
}
@Override
public Mono<StatisticsSummary> getStatisticsSummaryWithCache(StatisticsQuery query) {
String cacheKey = buildCacheKey(query);
return redisUtil.get(cacheKey, StatisticsSummary.class)
.switchIfEmpty(
getStatisticsSummary(query)
.flatMap(summary -> {
try {
String json = objectMapper.writeValueAsString(summary);
return redisUtil.setWithExpire(cacheKey, json, cacheExpireSeconds)
.thenReturn(summary);
} catch (Exception e) {
log.error("Failed to serialize statistics summary", e);
return Mono.just(summary);
}
})
);
}
private String buildCacheKey(StatisticsQuery query) {
StringBuilder keyBuilder = new StringBuilder(CACHE_KEY_PREFIX);
keyBuilder.append("summary:");
if (query.getStartTime() != null) {
keyBuilder.append(query.getStartTime().toLocalDate().toString());
}
if (query.getEndTime() != null) {
keyBuilder.append("_").append(query.getEndTime().toLocalDate().toString());
}
if (query.getStatType() != null) {
keyBuilder.append("_").append(query.getStatType());
}
if (query.getPeriodType() != null) {
keyBuilder.append("_").append(query.getPeriodType());
}
return keyBuilder.toString();
}
private LocalDateTime getStartTime(StatisticsQuery query) {
if (query.getStartTime() != null) {
return query.getStartTime();
}
LocalDate today = LocalDate.now();
String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周一
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月第一天
return today.withDayOfMonth(1).atStartOfDay();
} else {
// 日统计:当天零点
return today.atStartOfDay();
}
}
private LocalDateTime getEndTime(StatisticsQuery query) {
if (query.getEndTime() != null) {
return query.getEndTime();
}
LocalDate today = LocalDate.now();
String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周日 23:59:59
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月最后一天 23:59:59
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else {
// 日统计:当前时间
return LocalDateTime.now();
}
}
/**
* 根据周期类型调整时间范围
* 用于定时任务中的周期统计
*/
private LocalDateTime[] adjustTimeRangeByPeriod(LocalDate date, String periodType) {
LocalDateTime startTime;
LocalDateTime endTime;
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
startTime = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
endTime = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
startTime = date.withDayOfMonth(1).atStartOfDay();
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else {
startTime = date.atStartOfDay();
endTime = date.plusDays(1).atStartOfDay();
}
return new LocalDateTime[]{startTime, endTime};
}
}
@@ -0,0 +1 @@
cn.novalon.gym.manage.datacount.config.DataCountAutoConfiguration
+152 -200
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -48,6 +48,11 @@
<artifactId>gym-checkIn</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-dataCount</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -2,6 +2,7 @@ package cn.novalon.gym.manage.app.config;
import cn.novalon.gym.manage.checkIn.handler.CheckInHandler;
import cn.novalon.gym.manage.datacount.handler.DataStatisticsHandler;
import cn.novalon.gym.manage.file.handler.SysFileHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
@@ -68,7 +69,8 @@ public class SystemRouter {
MemberCardTransactionHandler memberCardTransactionHandler,
GroupCourseHandler groupCourseHandler,
GroupCourseBookingHandler groupCourseBookingHandler,
CheckInHandler checkInHandler) {
CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler) {
return route()
// ========== 诊断路由 ==========
@@ -292,6 +294,18 @@ public class SystemRouter {
// ===== 签到数据导出 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
// ========================================
// ========== 数据统计模块路由 ============
// ========================================
// ===== 数据统计核心功能 =====
.GET("/api/datacount/summary", dataStatisticsHandler::getStatisticsSummary)
.GET("/api/datacount/member", dataStatisticsHandler::getMemberStatistics)
.GET("/api/datacount/booking", dataStatisticsHandler::getBookingStatistics)
.GET("/api/datacount/signin", dataStatisticsHandler::getSignInStatistics)
.GET("/api/datacount/history", dataStatisticsHandler::queryHistoricalStatistics)
.GET("/api/datacount/export", dataStatisticsHandler::exportStatistics)
.build();
}
}
@@ -8,6 +8,7 @@ spring:
name: gym-manage-api
main:
allow-bean-definition-overriding: true
web-application-type: reactive
cache:
type: none
autoconfigure:
@@ -0,0 +1,120 @@
-- 数据统计模块测试数据
-- 用于测试会员、预约、签到统计接口
-- 插入测试会员数据
INSERT INTO member_user (id, member_no, nickname, phone, created_at, updated_at, is_deleted) VALUES
(1001, 'M20260601001', '张三', '13800138001', '2026-06-01 08:00:00', '2026-06-01 08:00:00', false),
(1002, 'M20260601002', '李四', '13800138002', '2026-06-01 09:00:00', '2026-06-01 09:00:00', false),
(1003, 'M20260602003', '王五', '13800138003', '2026-06-02 10:00:00', '2026-06-02 10:00:00', false),
(1004, 'M20260603004', '赵六', '13800138004', '2026-06-03 11:00:00', '2026-06-03 11:00:00', false),
(1005, 'M20260609005', '钱七', '13800138005', '2026-06-09 08:00:00', '2026-06-09 08:00:00', false),
(1006, 'M20260609006', '孙八', '13800138006', '2026-06-09 09:00:00', '2026-06-09 09:00:00', false),
(1007, 'M20260609007', '周九', '13800138007', '2026-06-09 10:00:00', '2026-06-09 10:00:00', false),
(1008, 'M20260604008', '吴十', '13800138008', '2026-06-04 14:00:00', '2026-06-04 14:00:00', false),
(1009, 'M20260605009', '郑十一', '13800138009', '2026-06-05 15:00:00', '2026-06-05 15:00:00', false),
(1010, 'M20260606010', '王十二', '13800138010', '2026-06-06 16:00:00', '2026-06-06 16:00:00', false),
(1011, 'M20260607011', '陈十三', '13800138011', '2026-06-07 17:00:00', '2026-06-07 17:00:00', false),
(1012, 'M20260608012', '刘十四', '13800138012', '2026-06-08 18:00:00', '2026-06-08 18:00:00', false)
ON CONFLICT (id) DO NOTHING;
-- 插入测试签到记录数据 (今天的数据)
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
(2001, 1001, '2026-06-09 08:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2002, 1002, '2026-06-09 08:15:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
(2003, 1003, '2026-06-09 08:30:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
(2004, 1004, '2026-06-09 09:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2005, 1005, '2026-06-09 09:15:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
(2006, 1006, '2026-06-09 09:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2007, 1007, '2026-06-09 10:00:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
(2008, 1008, '2026-06-09 10:15:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2009, 1009, '2026-06-09 10:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
(2010, 1010, '2026-06-09 11:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2011, 1011, '2026-06-09 11:15:00', 'FACE', 'FAIL', 'MINI_PROGRAM', false),
(2012, 1012, '2026-06-09 11:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false)
ON CONFLICT (id) DO NOTHING;
-- 插入测试签到记录数据 (昨天的数据)
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
(2013, 1001, '2026-06-08 07:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2014, 1002, '2026-06-08 08:00:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
(2015, 1003, '2026-06-08 08:30:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
(2016, 1004, '2026-06-08 09:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2017, 1005, '2026-06-08 09:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
(2018, 1006, '2026-06-08 10:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2019, 1007, '2026-06-08 10:30:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
(2020, 1008, '2026-06-08 11:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false)
ON CONFLICT (id) DO NOTHING;
-- 插入测试签到记录数据 (前天的数据)
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
(2021, 1001, '2026-06-07 07:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2022, 1002, '2026-06-07 07:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
(2023, 1003, '2026-06-07 08:00:00', 'FACE', 'FAIL', 'MINI_PROGRAM', false),
(2024, 1004, '2026-06-07 08:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
(2025, 1005, '2026-06-07 09:00:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false)
ON CONFLICT (id) DO NOTHING;
-- 插入测试团课数据
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, created_at, updated_at) VALUES
(3001, '瑜伽入门', 1, 1, '2026-06-09 08:00:00', '2026-06-09 09:00:00', 20, 15, 0, '健身房A区', 'https://example.com/yoga.jpg', '适合初学者的瑜伽课程', '2026-06-01 10:00:00', '2026-06-01 10:00:00'),
(3002, '动感单车', 2, 2, '2026-06-09 09:30:00', '2026-06-09 10:30:00', 25, 20, 0, '健身房B区', 'https://example.com/spinning.jpg', '高强度有氧运动', '2026-06-01 11:00:00', '2026-06-01 11:00:00'),
(3003, '普拉提', 3, 1, '2026-06-09 14:00:00', '2026-06-09 15:00:00', 15, 10, 0, '健身房C区', 'https://example.com/pilates.jpg', '核心力量训练', '2026-06-01 12:00:00', '2026-06-01 12:00:00')
ON CONFLICT (id) DO NOTHING;
-- 插入测试团课预约数据 (今天的数据)
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
(4001, 1001, 1, 3001, '2026-06-09 08:00:00', '2', '2026-06-08 20:00:00', '2026-06-09 08:30:00'),
(4002, 1002, 2, 3001, '2026-06-09 08:00:00', '2', '2026-06-08 21:00:00', '2026-06-09 08:30:00'),
(4003, 1003, 3, 3001, '2026-06-09 08:00:00', '3', '2026-06-08 22:00:00', '2026-06-09 09:00:00'),
(4004, 1004, 4, 3002, '2026-06-09 09:30:00', '2', '2026-06-08 19:00:00', '2026-06-09 09:30:00'),
(4005, 1005, 5, 3002, '2026-06-09 09:30:00', '1', '2026-06-08 20:30:00', '2026-06-09 09:00:00'),
(4006, 1006, 6, 3002, '2026-06-09 09:30:00', '2', '2026-06-08 21:30:00', '2026-06-09 09:30:00'),
(4007, 1007, 7, 3003, '2026-06-09 14:00:00', '2', '2026-06-08 22:30:00', '2026-06-09 14:00:00'),
(4008, 1008, 8, 3003, '2026-06-09 14:00:00', '3', '2026-06-09 08:00:00', '2026-06-09 14:00:00'),
(4009, 1009, 9, 3003, '2026-06-09 14:00:00', '2', '2026-06-09 09:00:00', '2026-06-09 14:00:00'),
(4010, 1010, 10, 3001, '2026-06-09 08:00:00', '2', '2026-06-09 07:00:00', '2026-06-09 08:00:00'),
(4011, 1011, 11, 3002, '2026-06-09 09:30:00', '1', '2026-06-09 08:30:00', '2026-06-09 09:00:00'),
(4012, 1012, 12, 3003, '2026-06-09 14:00:00', '2', '2026-06-09 10:00:00', '2026-06-09 14:00:00')
ON CONFLICT (id) DO NOTHING;
-- 插入测试团课预约数据 (昨天的数据)
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
(4013, 1001, 1, 3001, '2026-06-08 08:00:00', '2', '2026-06-07 20:00:00', '2026-06-08 08:30:00'),
(4014, 1002, 2, 3001, '2026-06-08 08:00:00', '2', '2026-06-07 21:00:00', '2026-06-08 08:30:00'),
(4015, 1003, 3, 3002, '2026-06-08 09:30:00', '3', '2026-06-07 22:00:00', '2026-06-08 09:30:00'),
(4016, 1004, 4, 3002, '2026-06-08 09:30:00', '2', '2026-06-07 19:00:00', '2026-06-08 09:30:00'),
(4017, 1005, 5, 3003, '2026-06-08 14:00:00', '1', '2026-06-07 20:30:00', '2026-06-08 13:00:00'),
(4018, 1006, 6, 3003, '2026-06-08 14:00:00', '2', '2026-06-07 21:30:00', '2026-06-08 14:00:00')
ON CONFLICT (id) DO NOTHING;
-- 插入测试团课预约数据 (前天的数据)
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
(4019, 1001, 1, 3002, '2026-06-07 09:30:00', '2', '2026-06-06 20:00:00', '2026-06-07 09:30:00'),
(4020, 1002, 2, 3002, '2026-06-07 09:30:00', '2', '2026-06-06 21:00:00', '2026-06-07 09:30:00'),
(4021, 1003, 3, 3003, '2026-06-07 14:00:00', '2', '2026-06-06 22:00:00', '2026-06-07 14:00:00'),
(4022, 1004, 4, 3003, '2026-06-07 14:00:00', '3', '2026-06-06 19:00:00', '2026-06-07 14:00:00')
ON CONFLICT (id) DO NOTHING;
-- 预期统计结果 (今日):
-- 会员统计:
-- 新增会员: 3 (1005, 1006, 1007)
-- 活跃会员: 12 (所有会员今日或近期有活动)
-- 总会员数: 12
-- 签到会员: 12
-- 预约会员: 9
-- 取消会员: 2
-- 预约统计:
-- 总预约: 12
-- 取消: 2
-- 出席: 8
-- 缺席: 2
-- 出席率: 8/10 = 80%
-- 取消率: 2/12 = 16.67%
-- 签到统计:
-- 总签到: 12
-- 成功: 11
-- 失败: 1
-- 成功率: 11/12 = 91.67%
-- 类型分布: QR_CODE=5, MANUAL=4, FACE=3
@@ -1,74 +0,0 @@
-- ============================================
-- 会员到店签到记录表
-- 版本: 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 '记录更新时间';
@@ -56,6 +56,7 @@ public class SecurityConfig {
.pathMatchers("/api/admin/member/**").permitAll()
.pathMatchers("/api/member-cards/**").permitAll()
.pathMatchers("/api/member-card-records/**").permitAll()
.pathMatchers("/**").permitAll()
.pathMatchers("/api/member-card-transactions/**").permitAll()
.pathMatchers("/api/checkIn/**").permitAll();
+1
View File
@@ -45,6 +45,7 @@
<module>gym-member</module>
<module>gym-groupCourse</module>
<module>gym-checkIn</module>
<module>gym-dataCount</module>
</modules>
<dependencyManagement>