Author SHA1 Message Date
future 58feae78b7 合并dev分支 2026-06-24 22:32:38 +08:00
future c44478a9cb 完成登录功能 2026-06-24 22:28:03 +08:00
future 972cbf1e56 修复丢失文件 2026-06-24 22:28:02 +08:00
future 0f8fe00501 feat: 添加签到模块和数据库表结构
- 新增gym-checkIn签到模块
- 创建sign_in_record签到记录表(V13迁移脚本)
- 添加签到相关路由配置
- 更新应用配置以支持签到模块
- 添加Redis密钥常量
2026-06-24 22:28:02 +08:00
future ce6bc107cc 签到模块 2026-06-24 22:28:02 +08:00
future e0df10752c 修复项目构建 2026-06-24 22:28:02 +08:00
future 3586a7d74b 登录 2026-06-23 22:47:35 +08:00
liwentao 8da58a8f51 新增功能:团课签到前验证是否到店签到 2026-06-18 10:48:23 +08:00
liwentao 0b2146f237 新增团课扫码签到功能,文档已更新 2026-06-18 10:11:47 +08:00
liwentao 7cc9a68144 新增团课推荐功能 2026-06-15 16:43:12 +08:00
liwentao b5c8a087dd 新增团课多条件查询 2026-06-15 14:09:19 +08:00
284 changed files with 3858 additions and 42285 deletions
@@ -1,407 +0,0 @@
-- Novalon管理系统数据库初始化脚本
-- 版本: V1
-- 描述: 创建所有核心表结构(合并版)
-- ============================================
-- 用户与角色相关表
-- ============================================
-- 用户表
CREATE TABLE IF NOT EXISTS sys_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
email VARCHAR(100),
phone VARCHAR(20),
nickname VARCHAR(100),
status INTEGER DEFAULT 1,
role_id BIGINT,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 角色表
CREATE TABLE IF NOT EXISTS sys_role (
id BIGSERIAL PRIMARY KEY,
role_name VARCHAR(100) NOT NULL,
role_key VARCHAR(100) NOT NULL UNIQUE,
role_sort INTEGER DEFAULT 0,
status INTEGER DEFAULT 1,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 用户角色关联表(支持多对多关系)
CREATE TABLE IF NOT EXISTS user_role (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
CONSTRAINT fk_user_role_user FOREIGN KEY (user_id) REFERENCES sys_user(id) ON DELETE CASCADE,
CONSTRAINT fk_user_role_role FOREIGN KEY (role_id) REFERENCES sys_role(id) ON DELETE CASCADE,
CONSTRAINT uk_user_role UNIQUE (user_id, role_id)
);
-- ============================================
-- 权限相关表
-- ============================================
-- 权限表
CREATE TABLE IF NOT EXISTS sys_permission (
id BIGSERIAL PRIMARY KEY,
permission_name VARCHAR(100) NOT NULL,
permission_code VARCHAR(100) NOT NULL UNIQUE,
resource VARCHAR(200) NOT NULL,
action VARCHAR(50) NOT NULL,
description VARCHAR(500),
status INTEGER DEFAULT 1,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 角色权限关联表
CREATE TABLE IF NOT EXISTS sys_role_permission (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES sys_role(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES sys_permission(id) ON DELETE CASCADE,
UNIQUE (role_id, permission_id)
);
-- ============================================
-- 菜单相关表
-- ============================================
-- 菜单表
CREATE TABLE IF NOT EXISTS sys_menu (
id BIGSERIAL PRIMARY KEY,
menu_name VARCHAR(50) NOT NULL,
parent_id BIGINT DEFAULT 0,
order_num INTEGER DEFAULT 0,
menu_type VARCHAR(1) DEFAULT 'C',
perms VARCHAR(100),
component VARCHAR(200),
status INTEGER DEFAULT 1,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 字典相关表
-- ============================================
-- 字典类型表
CREATE TABLE IF NOT EXISTS sys_dict_type (
id BIGSERIAL PRIMARY KEY,
dict_name VARCHAR(100) NOT NULL,
dict_type VARCHAR(100) NOT NULL UNIQUE,
status VARCHAR(1) DEFAULT '0',
remark VARCHAR(500),
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 字典数据表
CREATE TABLE IF NOT EXISTS sys_dict_data (
id BIGSERIAL PRIMARY KEY,
dict_sort INTEGER DEFAULT 0,
dict_label VARCHAR(100) NOT NULL,
dict_value VARCHAR(100) NOT NULL,
dict_type VARCHAR(100) NOT NULL,
css_class VARCHAR(100),
list_class VARCHAR(100),
is_default VARCHAR(1) DEFAULT 'N',
status VARCHAR(1) DEFAULT '0',
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 字典表(通用字典)
CREATE TABLE IF NOT EXISTS sys_dictionary (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
value VARCHAR(500),
remark VARCHAR(500),
sort INTEGER DEFAULT 0,
create_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 系统配置表
-- ============================================
-- 系统配置表
CREATE TABLE IF NOT EXISTS sys_config (
id BIGSERIAL PRIMARY KEY,
config_name VARCHAR(100) NOT NULL,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value VARCHAR(500) NOT NULL,
config_type VARCHAR(1) DEFAULT 'N',
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 日志相关表
-- ============================================
-- 登录日志表
CREATE TABLE IF NOT EXISTS sys_login_log (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50),
ip VARCHAR(50),
location VARCHAR(255),
browser VARCHAR(50),
os VARCHAR(50),
status VARCHAR(1),
message VARCHAR(255),
login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 异常日志表
CREATE TABLE IF NOT EXISTS sys_exception_log (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50),
title VARCHAR(100),
exception_name VARCHAR(100),
method_name VARCHAR(255),
method_params TEXT,
exception_msg TEXT,
exception_stack TEXT,
ip VARCHAR(50),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 操作日志表
CREATE TABLE IF NOT EXISTS operation_log (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50),
operation VARCHAR(100),
method VARCHAR(200),
params TEXT,
result TEXT,
ip VARCHAR(50),
duration BIGINT,
status VARCHAR(1) DEFAULT '0',
error_msg TEXT,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 审计日志表
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(100) NOT NULL,
entity_id BIGINT,
operation_type VARCHAR(20) NOT NULL,
operator VARCHAR(100),
operation_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
before_data JSONB,
after_data JSONB,
changed_fields TEXT[],
ip_address VARCHAR(50),
user_agent TEXT,
description TEXT,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 审计日志归档表
CREATE TABLE IF NOT EXISTS audit_log_archive (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(100) NOT NULL,
entity_id BIGINT,
operation_type VARCHAR(20) NOT NULL,
operator VARCHAR(100),
operation_time TIMESTAMP,
before_data JSONB,
after_data JSONB,
changed_fields TEXT[],
ip_address VARCHAR(50),
user_agent TEXT,
description TEXT,
created_at TIMESTAMP,
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================
-- 通知与消息表
-- ============================================
-- 系统公告表
CREATE TABLE IF NOT EXISTS sys_notice (
id BIGSERIAL PRIMARY KEY,
notice_title VARCHAR(50) NOT NULL,
notice_type VARCHAR(1) NOT NULL,
notice_content TEXT,
status VARCHAR(1) DEFAULT '0',
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 用户消息表
CREATE TABLE IF NOT EXISTS sys_user_message (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
notice_id BIGINT,
message_title VARCHAR(255),
message_content TEXT,
is_read VARCHAR(1) DEFAULT '0',
read_time TIMESTAMP,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 文件管理表
-- ============================================
-- 文件管理表
CREATE TABLE IF NOT EXISTS sys_file (
id BIGSERIAL PRIMARY KEY,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
file_type VARCHAR(100),
file_extension VARCHAR(10),
storage_type VARCHAR(50),
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- OAuth2相关表
-- ============================================
-- OAuth2客户端表
CREATE TABLE IF NOT EXISTS oauth2_client (
id BIGSERIAL PRIMARY KEY,
client_id VARCHAR(100) NOT NULL UNIQUE,
client_secret VARCHAR(255) NOT NULL,
client_name VARCHAR(100),
web_server_redirect_uri VARCHAR(500),
scope VARCHAR(500),
authorized_grant_types VARCHAR(500),
access_token_validity_seconds INTEGER,
refresh_token_validity_seconds INTEGER,
auto_approve VARCHAR(1) DEFAULT 'false',
enabled VARCHAR(1) DEFAULT 'true',
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 表注释
-- ============================================
COMMENT ON TABLE sys_user IS '系统用户表';
COMMENT ON TABLE sys_role IS '系统角色表';
COMMENT ON TABLE user_role IS '用户角色关联表';
COMMENT ON TABLE sys_permission IS '系统权限表';
COMMENT ON TABLE sys_role_permission IS '角色权限关联表';
COMMENT ON TABLE sys_menu IS '系统菜单表';
COMMENT ON TABLE sys_dict_type IS '字典类型表';
COMMENT ON TABLE sys_dict_data IS '字典数据表';
COMMENT ON TABLE sys_dictionary IS '通用字典表';
COMMENT ON TABLE sys_config IS '系统配置表';
COMMENT ON TABLE sys_login_log IS '登录日志表';
COMMENT ON TABLE sys_exception_log IS '异常日志表';
COMMENT ON TABLE operation_log IS '操作日志表';
COMMENT ON TABLE audit_log IS '审计日志表';
COMMENT ON TABLE audit_log_archive IS '审计日志归档表';
COMMENT ON TABLE sys_notice IS '系统公告表';
COMMENT ON TABLE sys_user_message IS '用户消息表';
COMMENT ON TABLE sys_file IS '文件管理表';
COMMENT ON TABLE oauth2_client IS 'OAuth2客户端表';
COMMENT ON TABLE sys_exception_log IS '异常日志表';
COMMENT ON COLUMN sys_exception_log.id IS '主键ID';
COMMENT ON COLUMN sys_exception_log.username IS '操作用户';
COMMENT ON COLUMN sys_exception_log.title IS '异常标题';
COMMENT ON COLUMN sys_exception_log.exception_name IS '异常名称';
COMMENT ON COLUMN sys_exception_log.method_name IS '方法名称';
COMMENT ON COLUMN sys_exception_log.method_params IS '方法参数';
COMMENT ON COLUMN sys_exception_log.exception_msg IS '异常消息';
COMMENT ON COLUMN sys_exception_log.exception_stack IS '异常堆栈';
COMMENT ON COLUMN sys_exception_log.ip IS 'IP地址';
COMMENT ON COLUMN sys_exception_log.create_time IS '创建时间';
COMMENT ON TABLE audit_log IS '审计日志表';
COMMENT ON COLUMN audit_log.id IS '主键ID';
COMMENT ON COLUMN audit_log.entity_type IS '实体类型(如User, Role等)';
COMMENT ON COLUMN audit_log.entity_id IS '实体ID';
COMMENT ON COLUMN audit_log.operation_type IS '操作类型(CREATE, UPDATE, DELETE';
COMMENT ON COLUMN audit_log.operator IS '操作人';
COMMENT ON COLUMN audit_log.operation_time IS '操作时间';
COMMENT ON COLUMN audit_log.before_data IS '变更前数据(JSON格式)';
COMMENT ON COLUMN audit_log.after_data IS '变更后数据(JSON格式)';
COMMENT ON COLUMN audit_log.changed_fields IS '变更字段列表';
COMMENT ON COLUMN audit_log.ip_address IS 'IP地址';
COMMENT ON COLUMN audit_log.description IS '操作描述';
COMMENT ON COLUMN audit_log.created_at IS '记录创建时间';
COMMENT ON TABLE audit_log_archive IS '审计日志归档表';
COMMENT ON COLUMN audit_log_archive.id IS '主键ID';
COMMENT ON COLUMN audit_log_archive.entity_type IS '实体类型(如User, Role等)';
COMMENT ON COLUMN audit_log_archive.entity_id IS '实体ID';
COMMENT ON COLUMN audit_log_archive.operation_type IS '操作类型(CREATE, UPDATE, DELETE';
COMMENT ON COLUMN audit_log_archive.operator IS '操作人';
COMMENT ON COLUMN audit_log_archive.operation_time IS '操作时间';
COMMENT ON COLUMN audit_log_archive.before_data IS '变更前数据(JSON格式)';
COMMENT ON COLUMN audit_log_archive.after_data IS '变更后数据(JSON格式)';
COMMENT ON COLUMN audit_log_archive.changed_fields IS '变更字段列表';
COMMENT ON COLUMN audit_log_archive.ip_address IS 'IP地址';
COMMENT ON COLUMN audit_log_archive.user_agent IS '用户代理';
COMMENT ON COLUMN audit_log_archive.description IS '操作描述';
COMMENT ON COLUMN audit_log_archive.created_at IS '记录创建时间';
COMMENT ON COLUMN audit_log_archive.archived_at IS '归档时间';
+182 -8
View File
@@ -14,6 +14,7 @@
3. [团课管理接口](#团课管理接口)
- [获取所有团课](#获取所有团课)
- [分页获取团课](#分页获取团课)
- [多条件查询团课](#多条件查询团课)
- [根据ID获取团课详情](#根据ID获取团课详情)
- [创建团课](#创建团课)
- [更新团课](#更新团课)
@@ -154,6 +155,149 @@
---
### 多条件查询团课
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/search` |
| **所属文件** | `GroupCourseHandler.java` |
**功能说明**: 支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询,默认不查询不可预约的团课(已取消或已结束)。
**请求体**:
```json
{
"courseName": "瑜伽",
"courseType": 1,
"startDate": "2026-06-01T00:00:00",
"endDate": "2026-06-30T23:59:59",
"timePeriod": "morning",
"priceSort": "asc",
"remainingMost": true,
"page": 0,
"size": 10
}
```
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| courseName | String | 否 | - | 课程名称(模糊查询,不区分大小写) |
| courseType | Long | 否 | - | 课程类型ID |
| startDate | LocalDateTime | 否 | - | 查询开始日期 |
| endDate | LocalDateTime | 否 | - | 查询结束日期 |
| timePeriod | String | 否 | - | 时间段:`morning`(6:00-12:00)、`afternoon`(12:00-18:00)、`evening`(18:00-24:00) |
| priceSort | String | 否 | - | 价格排序:`asc`(从低到高)、`desc`(从高到低) |
| remainingMost | Boolean | 否 | false | 是否按剩余名额最多排序 |
| page | Integer | 否 | 0 | 页码,从0开始 |
| size | Integer | 否 | 10 | 每页数量,最大100 |
**查询条件优先级说明**:
| 优先级 | 条件类型 | 说明 |
|--------|----------|------|
| 1 | 默认过滤 | 自动过滤已删除和不可预约的团课(status != 0 |
| 2 | 基础筛选 | courseName、courseType、startDate、endDate、timePeriod |
| 3 | 排序规则 | remainingMost优先,其次priceSort,默认按startTime升序 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "查询成功",
"data": {
"content": [
{
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 5,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程",
"pointCardAmount": 1,
"storedValueAmount": 50.00,
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00"
}
],
"totalPages": 3,
"totalElements": 25,
"currentPage": 0,
"pageSize": 10,
"first": true,
"last": false
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "查询失败的原因"
}
```
**使用示例**:
1. **查询瑜伽课程**
```json
{
"courseName": "瑜伽"
}
```
2. **查询特定类型的早晨课程**
```json
{
"courseType": 1,
"timePeriod": "morning"
}
```
3. **查询下周的课程,按价格从低到高排序**
```json
{
"startDate": "2026-06-16T00:00:00",
"endDate": "2026-06-22T23:59:59",
"priceSort": "asc"
}
```
4. **查询剩余名额最多的晚间课程**
```json
{
"timePeriod": "evening",
"remainingMost": true
}
```
5. **多条件组合查询**
```json
{
"courseName": "瑜伽",
"courseType": 1,
"startDate": "2026-06-01T00:00:00",
"endDate": "2026-06-30T23:59:59",
"timePeriod": "morning",
"priceSort": "asc",
"remainingMost": true,
"page": 0,
"size": 10
}
```
---
### 根据ID获取团课详情
| 属性 | 值 |
@@ -460,26 +604,26 @@
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/{courseId}/signin` |
| **接口路径** | `/api/groupCourse/signin/{memberId}` |
| **所属文件** | `GroupCourseHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 是 | 团课ID |
| memberId | Long | 是 | 会员ID |
**请求体**:
```json
{
"memberId": 1001
"courseId": 1
}
```
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| memberId | Long | **是** | 会员ID |
| courseId | Long | **是** | 团课ID |
**成功响应** (200 OK):
@@ -494,16 +638,39 @@
}
```
**失败响应** (400 Bad Request):
**失败响应** (400 Bad Request) —— 校验规则及对应错误信息:
| 序号 | 校验规则 | 错误信息 |
|------|----------|----------|
| 1 | 团课不存在或已被删除 | `团课不存在或已删除` |
| 2 | 团课状态为"已取消" | `团课已取消,无法签到` |
| 3 | 团课状态为"已结束" | `团课已结束,无法签到` |
| 4 | 当前时间早于开课前2小时 | `未到签到时间,请在开课前2小时内签到` |
| 5 | 当前时间晚于团课结束时间 | `团课已结束,无法签到` |
| 6 | 课程当前人数已达上限 | `课程已满员,无法签到` |
| 7 | 用户今日无到店签到记录 | `请先完成到店签到` |
| 8 | 到店签到状态非SUCCESS | `到店签到未成功,请重新签到` |
| 9 | 用户未预约此课程 | `您未预约此课程,无法签到` |
| - | 请求体为空 | `请求体不能为空` |
| - | 请求体缺少courseId | `courseId不能为空` |
> **说明**: 校验按上表顺序依次执行,命中第一个失败条件即返回对应错误信息。
**失败响应示例**:
```json
{
"success": false,
"message": "课程已满员"
"message": "未到签到时间,请在开课前2小时内签到"
}
```
---
```json
{
"success": false,
"message": "您未预约此课程,无法签到"
}
```
### 删除团课
@@ -1493,7 +1660,14 @@
### 团课管理
1. **创建团课**:课程名称为必填项
2. **取消团课**:需提前24小时通知,否则拒绝操作
3. **团课签到**验证课程状态必须为正常,且未达最大人数限制
3. **团课签到**严格按以下顺序校验,任一不通过即拒绝签到:
- 团课是否存在且未被删除
- 团课状态不为"已取消"
- 团课状态不为"已结束"(含已过结束时间)
- 当前时间在开课前2小时内(签到时间窗口)
- 课程当前人数未达到最大人数上限
- 用户今日已成功到店签到(查询 sign_in_record 表当日 SUCCESS 记录)
- 用户已预约该课程(有效预约)
4. **删除团课**:采用软删除机制,数据保留可恢复
### 团课预约
+77
View File
@@ -0,0 +1,77 @@
<?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 http://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>
</parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-auth</artifactId>
<packaging>jar</packaging>
<name>Gym Auth</name>
<description>Authentication module for Gym Management System</description>
<dependencies>
<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>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-member</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,23 @@
package cn.novalon.gym.manage.auth.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "alibaba.cloud.sms")
public class SmsProperties {
private String accessKeyId;
private String accessKeySecret;
private String signName;
private String templateCode;
private int codeLength = 4;
private long codeExpireSeconds = 300;
}
@@ -0,0 +1,23 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhoneCodeLoginDto {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = "^\\d{4,6}$", message = "验证码格式不正确")
private String code;
}
@@ -0,0 +1,23 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhoneLoginDto {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
private String nickname;
private String avatar;
}
@@ -0,0 +1,58 @@
package cn.novalon.gym.manage.auth.handler;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
@Tag(name = "手机号认证", description = "手机号一键登录与验证码登录")
public class PhoneAuthHandler {
private final PhoneAuthService phoneAuthService;
@Operation(summary = "手机号一键登录", description = "使用uniapp官方运营商认证,直接手机号登录或注册")
public Mono<ServerResponse> oneClickLogin(ServerRequest request) {
log.info("收到手机号一键登录请求");
return request.bodyToMono(PhoneLoginDto.class)
.flatMap(phoneAuthService::oneClickLogin)
.flatMap(response -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response));
}
@Operation(summary = "发送短信验证码", description = "使用阿里云发送短信验证码")
public Mono<ServerResponse> sendSmsCode(ServerRequest request) {
log.info("收到发送短信验证码请求");
return request.bodyToMono(PhoneLoginDto.class)
.flatMap(dto -> phoneAuthService.sendSmsCode(dto.getPhone()))
.flatMap(success -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("success", success, "message", success ? "验证码发送成功" : "验证码发送失败")));
}
@Operation(summary = "手机号验证码登录", description = "使用阿里云短信验证码登录或注册")
public Mono<ServerResponse> codeLogin(ServerRequest request) {
log.info("收到手机号验证码登录请求");
return request.bodyToMono(PhoneCodeLoginDto.class)
.flatMap(phoneAuthService::codeLogin)
.flatMap(response -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response));
}
}
@@ -0,0 +1,15 @@
package cn.novalon.gym.manage.auth.service;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.vo.PhoneLoginVO;
import reactor.core.publisher.Mono;
public interface PhoneAuthService {
Mono<PhoneLoginVO> oneClickLogin(PhoneLoginDto request);
Mono<Boolean> sendSmsCode(String phone);
Mono<PhoneLoginVO> codeLogin(PhoneCodeLoginDto request);
}
@@ -0,0 +1,12 @@
package cn.novalon.gym.manage.auth.service;
import reactor.core.publisher.Mono;
public interface SmsService {
Mono<Boolean> sendVerificationCode(String phone);
Mono<Boolean> verifyCode(String phone, String code);
Mono<String> getVerificationCode(String phone);
}
@@ -0,0 +1,177 @@
package cn.novalon.gym.manage.auth.service.impl;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
import cn.novalon.gym.manage.auth.service.SmsService;
import cn.novalon.gym.manage.auth.vo.PhoneLoginVO;
import cn.novalon.gym.manage.common.exception.ErrorCode;
import cn.novalon.gym.manage.common.exception.SystemException;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.es.entity.MemberES;
import cn.novalon.gym.manage.member.es.repository.MemberESRepository;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.EsSyncUtils;
import cn.novalon.gym.manage.member.util.MemberNoGenerator;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class PhoneAuthServiceImpl implements PhoneAuthService {
private final IMemberRepository memberRepository;
private final MemberESRepository memberESRepository;
private final EsSyncUtils esSyncUtils;
private final JwtTokenProvider jwtTokenProvider;
private final SmsService smsService;
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
@PostConstruct
public void init() {
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
}
@Override
public Mono<PhoneLoginVO> oneClickLogin(PhoneLoginDto request) {
log.info("手机号一键登录, phone: {}", request.getPhone());
String encryptedPhone = encryptPhone(request.getPhone());
return memberRepository.findByPhone(encryptedPhone)
.flatMap(existingMember -> {
log.info("手机号已注册,直接登录, memberId: {}", existingMember.getId());
return doLogin(existingMember, false, request);
})
.switchIfEmpty(Mono.defer(() -> {
log.info("手机号未注册,创建新会员");
return createNewMemberAndLogin(request, encryptedPhone);
}));
}
@Override
public Mono<Boolean> sendSmsCode(String phone) {
log.info("发送短信验证码, phone: {}", phone);
return smsService.sendVerificationCode(phone);
}
@Override
public Mono<PhoneLoginVO> codeLogin(PhoneCodeLoginDto request) {
log.info("手机号验证码登录, phone: {}", request.getPhone());
return smsService.verifyCode(request.getPhone(), request.getCode())
.flatMap(verified -> {
if (!verified) {
log.warn("验证码验证失败, phone: {}", request.getPhone());
return Mono.error(new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "验证码错误或已过期"));
}
String encryptedPhone = encryptPhone(request.getPhone());
return memberRepository.findByPhone(encryptedPhone)
.flatMap(existingMember -> {
log.info("手机号已注册,直接登录, memberId: {}", existingMember.getId());
return doLogin(existingMember, false, null);
})
.switchIfEmpty(Mono.defer(() -> {
log.info("手机号未注册,创建新会员");
PhoneLoginDto registerRequest = new PhoneLoginDto();
registerRequest.setPhone(request.getPhone());
return createNewMemberAndLogin(registerRequest, encryptedPhone);
}));
});
}
private Mono<PhoneLoginVO> createNewMemberAndLogin(PhoneLoginDto request, String encryptedPhone) {
String memberNo = MemberNoGenerator.generate();
log.info("生成会员号: {}", memberNo);
Member member = new Member();
member.setMemberNo(memberNo);
member.setPhone(encryptedPhone);
member.setNickname(request != null ? request.getNickname() : null);
member.setAvatar(request != null ? request.getAvatar() : null);
member.setLastLoginAt(LocalDateTime.now());
member.setIsDeleted(false);
return memberRepository.save(member)
.doOnSuccess(memberSyncer::sync)
.flatMap(savedMember -> {
log.info("新会员创建成功, memberId: {}, memberNo: {}", savedMember.getId(), savedMember.getMemberNo());
return doLogin(savedMember, true, request);
});
}
private Mono<PhoneLoginVO> doLogin(Member member, boolean isNewUser, PhoneLoginDto request) {
log.info("登录成功, memberId: {}", member.getId());
member.setLastLoginAt(LocalDateTime.now());
if (isNewUser && request != null) {
if ((member.getNickname() == null || member.getNickname().isEmpty())
&& request.getNickname() != null && !request.getNickname().isEmpty()) {
member.setNickname(request.getNickname());
}
if ((member.getAvatar() == null || member.getAvatar().isEmpty())
&& request.getAvatar() != null && !request.getAvatar().isEmpty()) {
member.setAvatar(request.getAvatar());
}
}
return memberRepository.save(member)
.doOnSuccess(memberSyncer::sync)
.map(this::buildLoginResponse);
}
private PhoneLoginVO buildLoginResponse(Member member) {
boolean needCompleteInfo = member.getNickname() == null || member.getNickname().isEmpty();
List<String> roles = new ArrayList<>();
String accessToken = jwtTokenProvider.generateToken(String.valueOf(member.getId()), member.getId(), roles);
log.info("JWT Token 生成成功, memberId: {}", member.getId());
PhoneLoginVO vo = new PhoneLoginVO();
vo.setMemberId(member.getId());
vo.setMemberNo(member.getMemberNo());
vo.setAccessToken(accessToken);
vo.setRefreshToken(accessToken);
vo.setExpiresIn(86400);
vo.setIsNewUser(member.getCreatedAt() == null ? false :
member.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(1)));
vo.setNeedCompleteInfo(needCompleteInfo);
vo.setNickname(member.getNickname());
vo.setAvatar(member.getAvatar());
vo.setPhone(decryptPhone(member.getPhone()));
return vo;
}
private String encryptPhone(String phoneNumber) {
try {
return AesUtil.encrypt(phoneNumber);
} catch (Exception e) {
log.error("手机号加密失败", e);
throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "手机号加密失败");
}
}
private String decryptPhone(String encryptedPhone) {
try {
return AesUtil.decrypt(encryptedPhone);
} catch (Exception e) {
log.error("手机号解密失败", e);
return null;
}
}
}
@@ -0,0 +1,198 @@
package cn.novalon.gym.manage.auth.service.impl;
import cn.novalon.gym.manage.auth.config.SmsProperties;
import cn.novalon.gym.manage.auth.service.SmsService;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil;
import com.aliyuncs.CommonRequest;
import com.aliyuncs.CommonResponse;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@Slf4j
@Service
@RequiredArgsConstructor
public class SmsServiceImpl implements SmsService {
private static final long SEND_INTERVAL_SECONDS = 60;
private static final long CODE_EXPIRE_SECONDS = 300;
private final SmsProperties smsProperties;
private final RedisUtil redisUtil;
private final ObjectMapper objectMapper;
@Override
public Mono<Boolean> sendVerificationCode(String phone) {
log.info("发送短信验证码, phone: {}", phone);
String rateLimitKey = RedisKeyConstants.SMS_CODE + phone + ":rate_limit";
return redisUtil.get(rateLimitKey, Long.class)
.defaultIfEmpty(0L)
.flatMap(lastSendTime -> {
long currentTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
if (currentTime - lastSendTime < SEND_INTERVAL_SECONDS) {
long remainingSeconds = SEND_INTERVAL_SECONDS - (currentTime - lastSendTime);
log.warn("发送频率限制, phone: {}, 剩余时间: {}秒", phone, remainingSeconds);
return Mono.just(false);
}
return Mono.fromCallable(() -> {
try {
IAcsClient client = createClient();
CommonRequest request = new CommonRequest();
request.setSysMethod(MethodType.POST);
request.setSysDomain("dypnsapi.aliyuncs.com");
request.setSysVersion("2017-05-25");
request.setSysAction("SendSmsVerifyCode");
request.putQueryParameter("PhoneNumber", phone);
request.putQueryParameter("SignName", smsProperties.getSignName());
request.putQueryParameter("TemplateCode", smsProperties.getTemplateCode());
request.putQueryParameter("TemplateParam", "{\"code\":\"##code##\",\"min\":\"5\"}");
request.putQueryParameter("ReturnVerifyCode", "true");
log.info("阿里云号码认证请求参数 - signName: {}, templateCode: {}, templateParam: {}",
smsProperties.getSignName(),
smsProperties.getTemplateCode(),
"{\"code\":\"##code##\",\"min\":\"5\"}");
CommonResponse response = client.getCommonResponse(request);
String responseData = response.getData();
log.info("阿里云号码认证原始响应: {}", responseData);
JsonNode jsonNode = objectMapper.readTree(responseData);
JsonNode codeNode = jsonNode.get("Code");
if (codeNode == null) {
log.error("阿里云响应中找不到Code字段, 原始响应: {}", responseData);
return false;
}
String code = codeNode.asText();
if ("OK".equals(code)) {
JsonNode requestIdNode = jsonNode.get("RequestId");
String requestId = requestIdNode != null ? requestIdNode.asText() : "unknown";
log.info("短信验证码发送成功, phone: {}, requestId: {}", phone, requestId);
// 提取验证码并存入Redis
JsonNode modelNode = jsonNode.get("Model");
if (modelNode != null) {
JsonNode verifyCodeNode = modelNode.get("VerifyCode");
if (verifyCodeNode != null) {
String verifyCode = verifyCodeNode.asText();
String smsCodeKey = RedisKeyConstants.SMS_CODE + phone;
redisUtil.setWithExpire(smsCodeKey, verifyCode, CODE_EXPIRE_SECONDS).subscribe();
log.info("验证码已存入Redis, phone: {}, key: {}, expire: {}秒", phone, smsCodeKey, CODE_EXPIRE_SECONDS);
} else {
log.warn("响应中未找到Model.VerifyCode字段, 原始响应: {}", responseData);
}
} else {
log.warn("响应中未找到Model字段, 原始响应: {}", responseData);
}
redisUtil.setWithExpire(rateLimitKey, currentTime, SEND_INTERVAL_SECONDS).subscribe();
return true;
}
JsonNode messageNode = jsonNode.get("Message");
String message = messageNode != null ? messageNode.asText() : "unknown";
log.error("短信验证码发送失败, phone: {}, code: {}, message: {}", phone, code, message);
return false;
} catch (Exception e) {
log.error("发送短信验证码异常, phone: {}, 异常信息: {}", phone, e.getMessage(), e);
return false;
}
});
});
}
@Override
public Mono<Boolean> verifyCode(String phone, String code) {
log.info("验证短信验证码, phone: {}", phone);
return Mono.fromCallable(() -> {
try {
IAcsClient client = createClient();
CommonRequest request = new CommonRequest();
request.setSysMethod(MethodType.POST);
request.setSysDomain("dypnsapi.aliyuncs.com");
request.setSysVersion("2017-05-25");
request.setSysAction("CheckSmsVerifyCode");
request.putQueryParameter("PhoneNumber", phone);
request.putQueryParameter("VerifyCode", code);
log.info("阿里云号码认证核验参数 - phone: {}, code: {}", phone, code);
CommonResponse response = client.getCommonResponse(request);
String responseData = response.getData();
log.info("阿里云号码认证核验原始响应: {}", responseData);
JsonNode jsonNode = objectMapper.readTree(responseData);
JsonNode codeNode = jsonNode.get("Code");
if (codeNode == null) {
log.error("阿里云核验响应中找不到Code字段, 原始响应: {}", responseData);
return false;
}
String responseCode = codeNode.asText();
JsonNode modelNode = jsonNode.get("Model");
JsonNode verifyResultNode = modelNode != null ? modelNode.get("VerifyResult") : null;
boolean verifyResult = verifyResultNode != null && "PASS".equals(verifyResultNode.asText());
if ("OK".equals(responseCode) && verifyResult) {
log.info("验证码验证成功, phone: {}", phone);
return true;
}
JsonNode messageNode = jsonNode.get("Message");
String message = messageNode != null ? messageNode.asText() : "unknown";
log.warn("验证码验证失败, phone: {}, code: {}, message: {}, result: {}",
phone, responseCode, message, verifyResult);
return false;
} catch (Exception e) {
log.error("验证短信验证码异常, phone: {}, 异常信息: {}", phone, e.getMessage(), e);
return false;
}
});
}
@Override
public Mono<String> getVerificationCode(String phone) {
try {
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
return redisUtil.get(cacheKey, String.class);
} catch (Exception e) {
log.error("获取验证码异常, phone: {}", phone, e);
return Mono.empty();
}
}
private IAcsClient createClient() throws ClientException {
IClientProfile profile = DefaultProfile.getProfile(
"cn-hangzhou",
smsProperties.getAccessKeyId(),
smsProperties.getAccessKeySecret());
DefaultProfile.addEndpoint("cn-hangzhou", "cn-hangzhou", "Dypnsapi", "dypnsapi.aliyuncs.com");
return new DefaultAcsClient(profile);
}
}
@@ -0,0 +1,33 @@
package cn.novalon.gym.manage.auth.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhoneLoginVO {
private Long memberId;
private String memberNo;
private String phone;
private String accessToken;
private String refreshToken;
private Integer expiresIn;
private Boolean isNewUser;
private Boolean needCompleteInfo;
private String nickname;
private String avatar;
}
@@ -0,0 +1 @@
cn.novalon.gym.manage.auth.config.SmsProperties
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
<Match>
<Class name="~.*\.entity\..*" />
</Match>
<Match>
<Class name="~.*\.dto\..*" />
</Match>
<Match>
<Class name="~.*\.converter\..*" />
</Match>
</FindBugsFilter>
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.checkIn.config;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class WebSocketConfig {
@Autowired
private MyWebSocketHandler myWebSocketHandler;
/**
* 注册 WebSocket 路由映射
* 路径对应前端连接的 ws://xxx/webSocket/checkIn
*/
@Bean
public HandlerMapping webSocketMapping() {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/webSocket/checkIn", myWebSocketHandler);
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setUrlMap(map);
mapping.setOrder(10); // 设置优先级
return mapping;
}
/**
* 注册 WebSocket 处理器适配器(必须)
*/
@Bean
public WebSocketHandlerAdapter handlerAdapter() {
return new WebSocketHandlerAdapter();
}
}
@@ -206,4 +206,4 @@ public class SignInRecord {
public void restore() {
this.isDelete = false;
}
}
}
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.checkIn.handler;
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -12,6 +14,7 @@ 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 cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@@ -36,6 +39,7 @@ public class CheckInHandler {
public Mono<ServerResponse> checkIn(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
return request.bodyToMono(Map.class)
.flatMap(body -> {
String qrContent = (String) body.get("qrContent");
@@ -190,4 +194,4 @@ public class CheckInHandler {
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
}
}
@@ -78,4 +78,4 @@ public interface ICheckInService {
* @return 签到统计VO
*/
Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
}
}
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.checkIn.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.json.JSONUtil;
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
import cn.novalon.gym.manage.checkIn.constant.QRRedisKey;
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
@@ -19,8 +21,6 @@ import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -199,4 +199,4 @@ public class MyWebSocketHandler implements WebSocketHandler {
cleanupTimeoutConnections();
return qrContentToSink.size();
}
}
}
+14 -2
View File
@@ -100,8 +100,20 @@
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
+19
View File
@@ -86,6 +86,25 @@
<artifactId>gym-member</artifactId>
<version>${project.version}</version>
</dependency>
<!-- ZXing二维码生成库 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- 阿里云OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.1</version>
</dependency>
</dependencies>
<build>
@@ -1,16 +1,19 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
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;
import java.util.ArrayList;
import java.util.List;
@Repository
public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> {
@@ -38,4 +41,184 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
}
/**
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL
*/
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
// 默认不查询不可预约的团课(仅查询 status = '0'
conditions.add("status = '0'");
// 1. 团课名称模糊查询
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName");
}
// 2. 基于团课类型查询
if (query.getCourseType() != null) {
conditions.add("course_type = :courseType");
}
// 3. 基于日期时间段查询
if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate");
}
if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate");
}
// 4. 基于早晨/下午/夜晚时间段查询
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) {
case "morning":
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
break;
case "afternoon":
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
break;
case "evening":
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
break;
default:
break;
}
}
sql.append(" AND ").append(String.join(" AND ", conditions));
// 5. 价格排序 / 6. 剩余名额最多排序
boolean hasPriceSort = query.getPriceSort() != null && !query.getPriceSort().isEmpty();
boolean hasRemainingMost = query.getRemainingMost() != null && query.getRemainingMost();
if (hasPriceSort || hasRemainingMost) {
sql.append(" ORDER BY");
List<String> orderClauses = new ArrayList<>();
if (hasRemainingMost) {
orderClauses.add(" (max_members - current_members) DESC");
}
if (hasPriceSort) {
if ("asc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount ASC");
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount DESC");
}
}
sql.append(String.join(",", orderClauses));
} else {
sql.append(" ORDER BY start_time ASC");
}
// 分页
int page = query.getPage() != null ? query.getPage() : 0;
int size = query.getSize() != null ? query.getSize() : 10;
if (size < 1) size = 10;
if (size > 100) size = 100;
int offset = page * size;
sql.append(" LIMIT :limit OFFSET :offset");
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
}
if (query.getCourseType() != null) {
spec = spec.bind("courseType", query.getCourseType());
}
if (query.getStartDate() != null) {
spec = spec.bind("startDate", query.getStartDate());
}
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
spec = spec.bind("limit", size);
spec = spec.bind("offset", offset);
return spec.map((row, meta) -> {
GroupCourseEntity entity = new GroupCourseEntity();
entity.setId(row.get("id", Long.class));
entity.setCourseName(row.get("course_name", String.class));
entity.setCoachId(row.get("coach_id", Long.class));
entity.setCourseType(row.get("course_type", Long.class));
entity.setStartTime(row.get("start_time", LocalDateTime.class));
entity.setEndTime(row.get("end_time", LocalDateTime.class));
entity.setMaxMembers(row.get("max_members", Integer.class));
entity.setCurrentMembers(row.get("current_members", Integer.class));
String statusStr = row.get("status", String.class);
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
entity.setLocation(row.get("location", String.class));
entity.setCoverImage(row.get("cover_image", String.class));
entity.setDescription(row.get("description", String.class));
entity.setPointCardAmount(row.get("point_card_amount", Integer.class));
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
entity.setCreateBy(row.get("create_by", String.class));
entity.setUpdateBy(row.get("update_by", String.class));
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
return entity;
}).all();
}
/**
* 多条件查询团课总数
*/
default Mono<Long> countSearchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
conditions.add("status = '0'");
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName");
}
if (query.getCourseType() != null) {
conditions.add("course_type = :courseType");
}
if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate");
}
if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate");
}
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) {
case "morning":
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
break;
case "afternoon":
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
break;
case "evening":
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
break;
default:
break;
}
}
sql.append(" AND ").append(String.join(" AND ", conditions));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
}
if (query.getCourseType() != null) {
spec = spec.bind("courseType", query.getCourseType());
}
if (query.getStartDate() != null) {
spec = spec.bind("startDate", query.getStartDate());
}
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
return spec.map((row, meta) -> row.get(0, Long.class)).one();
}
}
@@ -0,0 +1,42 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseRecommendEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface GroupCourseRecommendDao extends R2dbcRepository<GroupCourseRecommendEntity, Long> {
Mono<GroupCourseRecommendEntity> findByIdAndDeletedAtIsNull(Long id);
Flux<GroupCourseRecommendEntity> findAllByDeletedAtIsNull();
Flux<GroupCourseRecommendEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<GroupCourseRecommendEntity> findByCourseIdAndDeletedAtIsNull(Long courseId);
Mono<GroupCourseRecommendEntity> findByCourseIdAndDeletedAtIsNullAndIsActiveTrue(Long courseId);
Flux<GroupCourseRecommendEntity> findByIsActiveTrueAndDeletedAtIsNull();
Flux<GroupCourseRecommendEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
@Modifying
@Query("UPDATE group_course_recommend SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course_recommend SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course_recommend SET deleted_at = :deletedAt WHERE course_id = :courseId")
Mono<Integer> softDeleteByCourseId(Long courseId, LocalDateTime deletedAt);
}
@@ -60,6 +60,10 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
private java.math.BigDecimal storedValueAmount;
//二维码路径
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath;
public String getCourseName() {
return courseName;
}
@@ -163,4 +167,12 @@ public class GroupCourse extends BaseDomain{
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount;
}
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
}
@@ -54,6 +54,9 @@ public class GroupCourseDetail extends BaseDomain {
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
private BigDecimal storedValueAmount;
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath;
// ===== 关联的类型信息 =====
@Schema(description = "类型信息")
@@ -187,6 +190,14 @@ public class GroupCourseDetail extends BaseDomain {
this.storedValueAmount = storedValueAmount;
}
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
public GroupCourseType getTypeInfo() {
return typeInfo;
}
@@ -0,0 +1,84 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class GroupCourseRecommend extends BaseDomain {
@Schema(description = "团课ID", example = "1")
private Long courseId;
@Schema(description = "推荐标题", example = "本周热门课程")
private String recommendTitle;
@Schema(description = "推荐内容", example = "这是一门非常棒的课程,快来参加吧!")
private String recommendContent;
@Schema(description = "推荐理由", example = "教练专业,课程内容丰富")
private String recommendReason;
@Schema(description = "优先级(数字越大优先级越高)", example = "10")
private Integer priority;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
@Schema(description = "团课信息")
private GroupCourse groupCourse;
public Long getCourseId() {
return courseId;
}
public void setCourseId(Long courseId) {
this.courseId = courseId;
}
public String getRecommendTitle() {
return recommendTitle;
}
public void setRecommendTitle(String recommendTitle) {
this.recommendTitle = recommendTitle;
}
public String getRecommendContent() {
return recommendContent;
}
public void setRecommendContent(String recommendContent) {
this.recommendContent = recommendContent;
}
public String getRecommendReason() {
return recommendReason;
}
public void setRecommendReason(String recommendReason) {
this.recommendReason = recommendReason;
}
public Integer getPriority() {
return priority;
}
public void setPriority(Integer priority) {
this.priority = priority;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public GroupCourse getGroupCourse() {
return groupCourse;
}
public void setGroupCourse(GroupCourse groupCourse) {
this.groupCourse = groupCourse;
}
}
@@ -0,0 +1,116 @@
package cn.novalon.gym.manage.groupcourse.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/**
* 团课多条件查询请求DTO
*
* @author 张翔
* @date 2026-06-14
*/
@Schema(description = "团课多条件查询参数")
public class GroupCourseQueryDto {
@Schema(description = "课程名称(模糊查询)", example = "瑜伽")
private String courseName;
@Schema(description = "课程类型ID", example = "1")
private Long courseType;
@Schema(description = "查询开始日期", example = "2026-06-01T00:00:00")
private LocalDateTime startDate;
@Schema(description = "查询结束日期", example = "2026-06-30T23:59:59")
private LocalDateTime endDate;
@Schema(description = "时间段:morning-早晨(6:00-12:00), afternoon-下午(12:00-18:00), evening-夜晚(18:00-24:00)", example = "morning")
private String timePeriod;
@Schema(description = "价格排序:asc-从低到高, desc-从高到低", example = "asc")
private String priceSort;
@Schema(description = "按剩余名额最多排序", example = "true")
private Boolean remainingMost;
@Schema(description = "页码", example = "0")
private Integer page = 0;
@Schema(description = "每页大小", example = "10")
private Integer size = 10;
// ===== Getters and Setters =====
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public Long getCourseType() {
return courseType;
}
public void setCourseType(Long courseType) {
this.courseType = courseType;
}
public LocalDateTime getStartDate() {
return startDate;
}
public void setStartDate(LocalDateTime startDate) {
this.startDate = startDate;
}
public LocalDateTime getEndDate() {
return endDate;
}
public void setEndDate(LocalDateTime endDate) {
this.endDate = endDate;
}
public String getTimePeriod() {
return timePeriod;
}
public void setTimePeriod(String timePeriod) {
this.timePeriod = timePeriod;
}
public String getPriceSort() {
return priceSort;
}
public void setPriceSort(String priceSort) {
this.priceSort = priceSort;
}
public Boolean getRemainingMost() {
return remainingMost;
}
public void setRemainingMost(Boolean remainingMost) {
this.remainingMost = remainingMost;
}
public Integer getPage() {
return page;
}
public void setPage(Integer page) {
this.page = page;
}
public Integer getSize() {
return size;
}
public void setSize(Integer size) {
this.size = size;
}
}
@@ -62,6 +62,10 @@ public class GroupCourseEntity extends BaseEntity {
@Column("stored_value_amount")
private java.math.BigDecimal storedValueAmount;
//二维码路径
@Column("qr_code_path")
private String qrCodePath;
public String getCourseName() {
return courseName;
}
@@ -165,4 +169,12 @@ public class GroupCourseEntity extends BaseEntity {
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount;
}
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
}
@@ -0,0 +1,77 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Table("group_course_recommend")
public class GroupCourseRecommendEntity extends BaseEntity {
@Column("course_id")
private Long courseId;
@Column("recommend_title")
private String recommendTitle;
@Column("recommend_content")
private String recommendContent;
@Column("recommend_reason")
private String recommendReason;
@Column("priority")
private Integer priority;
@Column("is_active")
private Boolean isActive;
public Long getCourseId() {
return courseId;
}
public void setCourseId(Long courseId) {
this.courseId = courseId;
}
public String getRecommendTitle() {
return recommendTitle;
}
public void setRecommendTitle(String recommendTitle) {
this.recommendTitle = recommendTitle;
}
public String getRecommendContent() {
return recommendContent;
}
public void setRecommendContent(String recommendContent) {
this.recommendContent = recommendContent;
}
public String getRecommendReason() {
return recommendReason;
}
public void setRecommendReason(String recommendReason) {
this.recommendReason = recommendReason;
}
public Integer getPriority() {
return priority;
}
public void setPriority(Integer priority) {
this.priority = priority;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}
@@ -5,6 +5,7 @@ import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
@@ -158,7 +159,7 @@ public class GroupCourseHandler {
@Operation(summary = "团课签到", description = "会员签到参加团课")
public Mono<ServerResponse> signIn(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
Long memberId = Long.valueOf(request.pathVariable("memberId"));
return request.bodyToMono(Map.class)
.flatMap(body -> {
@@ -169,15 +170,15 @@ public class GroupCourseHandler {
return ServerResponse.badRequest().bodyValue(response);
}
Object memberIdObj = body.get("memberId");
if (memberIdObj == null) {
Object courseIdObj = body.get("courseId");
if (courseIdObj == null) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "memberId不能为空");
response.put("message", "courseId不能为空");
return ServerResponse.badRequest().bodyValue(response);
}
Long memberId = ((Number) memberIdObj).longValue();
Long courseId = ((Number) courseIdObj).longValue();
return groupCourseService.signIn(courseId, memberId)
.flatMap(course -> {
@@ -215,6 +216,27 @@ public class GroupCourseHandler {
});
}
@Operation(summary = "多条件查询团课", description = "支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询")
public Mono<ServerResponse> searchGroupCourses(ServerRequest request) {
return request.bodyToMono(GroupCourseQueryDto.class)
.flatMap(query -> {
return groupCourseService.searchGroupCourses(query)
.flatMap(response -> {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "查询成功");
result.put("data", response);
return ServerResponse.ok().bodyValue(result);
});
})
.onErrorResume(error -> {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(errorResponse);
});
}
@Operation(summary = "测试-根据Key获取Redis缓存", description = "测试接口:根据传入的key值获取Redis中缓存的数据")
public Mono<ServerResponse> getCacheByKey(ServerRequest request) {
return request.bodyToMono(Map.class)
@@ -0,0 +1,164 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.util.HashMap;
import java.util.Map;
@Component
@Tag(name = "团课推荐管理", description = "团课推荐相关操作")
public class GroupCourseRecommendHandler {
private final IGroupCourseRecommendService recommendService;
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService) {
this.recommendService = recommendService;
}
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
public Mono<ServerResponse> getAllRecommendations(ServerRequest request) {
String sortBy = request.queryParam("sortBy").orElse("priority");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
return ServerResponse.ok()
.body(recommendService.findAll(sortBy, sortOrder), GroupCourseRecommend.class);
}
@Operation(summary = "获取所有启用的团课推荐", description = "获取系统中所有已启用的团课推荐列表(按优先级排序)")
public Mono<ServerResponse> getAllActiveRecommendations(ServerRequest request) {
return ServerResponse.ok()
.body(recommendService.findAllActive(), GroupCourseRecommend.class);
}
@Operation(summary = "根据ID获取团课推荐", description = "根据ID获取团课推荐详情")
public Mono<ServerResponse> getRecommendationById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.findById(id)
.flatMap(recommend -> ServerResponse.ok().bodyValue(recommend))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "根据团课ID获取推荐", description = "根据团课ID获取该团课的推荐信息")
public Mono<ServerResponse> getRecommendationsByCourseId(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
return ServerResponse.ok()
.body(recommendService.findByCourseId(courseId), GroupCourseRecommend.class);
}
@Operation(summary = "创建团课推荐", description = "创建新的团课推荐")
public Mono<ServerResponse> createRecommendation(ServerRequest request) {
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> {
if (recommend.getCourseId() == null) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "团课ID不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return recommendService.create(recommend)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐创建成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.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> updateRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> {
return recommendService.update(id, recommend)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐更新成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.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> deleteRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.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> enableRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.enable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐启用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.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> disableRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
}
@@ -0,0 +1,89 @@
package cn.novalon.gym.manage.groupcourse.initializer;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 项目启动时补全缺失的团课二维码
* 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并上传至阿里云OSS
*/
@Component
public class QrCodeInitializer implements CommandLineRunner {
private static final Logger logger = LoggerFactory.getLogger(QrCodeInitializer.class);
private final IGroupCourseRepository groupCourseRepository;
private final ObjectMapper objectMapper;
public QrCodeInitializer(IGroupCourseRepository groupCourseRepository,
ObjectMapper objectMapper) {
this.groupCourseRepository = groupCourseRepository;
this.objectMapper = objectMapper;
}
@Override
public void run(String... args) {
logger.info("===== 开始检查团课二维码缺失情况 =====");
groupCourseRepository.findByDeletedAtIsNull()
.filter(course -> course.getQrCodePath() == null || course.getQrCodePath().isEmpty())
.flatMap(course -> {
try {
logger.info("发现缺失二维码的团课 - id={}, name={}", course.getId(), course.getCourseName());
// 生成二维码内容:团课基础信息JSON
Map<String, Object> qrCodeContent = new HashMap<>();
qrCodeContent.put("id", course.getId());
qrCodeContent.put("courseName", course.getCourseName());
qrCodeContent.put("coachId", course.getCoachId());
qrCodeContent.put("courseType", course.getCourseType());
qrCodeContent.put("startTime", course.getStartTime() != null ? course.getStartTime().toString() : null);
qrCodeContent.put("endTime", course.getEndTime() != null ? course.getEndTime().toString() : null);
qrCodeContent.put("maxMembers", course.getMaxMembers());
qrCodeContent.put("location", course.getLocation());
qrCodeContent.put("description", course.getDescription());
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码并上传到阿里云OSS
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = "qr_" + uuid + "_" + timestamp + ".png";
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent, fileName);
course.setQrCodePath(ossUrl);
// 更新数据库
return groupCourseRepository.update(course)
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, ossUrl={}",
updated.getId(), updated.getCourseName(), ossUrl))
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), error.getMessage()));
} catch (Exception e) {
logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), e.getMessage(), e);
return Mono.empty();
}
})
.collectList()
.doOnSuccess(list -> logger.info("===== 团课二维码检查完毕,共补全 {} 个缺失二维码 =====", list.size()))
.onErrorResume(error -> {
logger.error("团课二维码初始化检查异常: {}", error.getMessage(), error);
return Mono.empty();
})
.subscribe();
}
}
@@ -0,0 +1,30 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseRecommendRepository {
Mono<GroupCourseRecommend> findById(Long id);
Flux<GroupCourseRecommend> findAll();
Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder);
Flux<GroupCourseRecommend> findAllActive();
Flux<GroupCourseRecommend> findByCourseId(Long courseId);
Mono<GroupCourseRecommend> findActiveByCourseId(Long courseId);
Mono<GroupCourseRecommend> save(GroupCourseRecommend recommend);
Mono<GroupCourseRecommend> update(GroupCourseRecommend recommend);
Mono<Void> deleteById(Long id);
Mono<Void> deleteByCourseId(Long courseId);
Mono<GroupCourseRecommend> updateActiveStatus(Long id, Boolean isActive);
}
@@ -4,6 +4,7 @@ package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import org.springframework.data.domain.Sort;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -29,4 +30,6 @@ public interface IGroupCourseRepository {
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
Flux<GroupCourse> findByCourseType(Long courseType);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
}
@@ -0,0 +1,136 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseRecommendDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseRecommendEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public class GroupCourseRecommendRepository implements IGroupCourseRecommendRepository {
private final GroupCourseRecommendDao recommendDao;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public GroupCourseRecommendRepository(GroupCourseRecommendDao recommendDao,
R2dbcEntityTemplate r2dbcEntityTemplate) {
this.recommendDao = recommendDao;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<GroupCourseRecommend> findById(Long id) {
return recommendDao.findByIdAndDeletedAtIsNull(id)
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAll() {
return recommendDao.findAllByDeletedAtIsNull()
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC
: Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return recommendDao.findAllByDeletedAtIsNull(sort)
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAllActive() {
return recommendDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "priority"))
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findByCourseId(Long courseId) {
return recommendDao.findByCourseIdAndDeletedAtIsNull(courseId)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> findActiveByCourseId(Long courseId) {
return recommendDao.findByCourseIdAndDeletedAtIsNullAndIsActiveTrue(courseId)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> save(GroupCourseRecommend recommend) {
GroupCourseRecommendEntity entity = toEntity(recommend);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
if (entity.getPriority() == null) {
entity.setPriority(0);
}
if (entity.getIsActive() == null) {
entity.setIsActive(true);
}
return recommendDao.save(entity)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> update(GroupCourseRecommend recommend) {
GroupCourseRecommendEntity entity = toEntity(recommend);
entity.setUpdatedAt(LocalDateTime.now());
return r2dbcEntityTemplate.update(entity)
.then(findById(recommend.getId()));
}
@Override
public Mono<Void> deleteById(Long id) {
return recommendDao.softDelete(id, LocalDateTime.now())
.then();
}
@Override
public Mono<Void> deleteByCourseId(Long courseId) {
return recommendDao.softDeleteByCourseId(courseId, LocalDateTime.now())
.then();
}
@Override
public Mono<GroupCourseRecommend> updateActiveStatus(Long id, Boolean isActive) {
return recommendDao.updateActiveStatus(id, isActive, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findById(id);
}
return Mono.empty();
});
}
private GroupCourseRecommend toDomain(GroupCourseRecommendEntity entity) {
if (entity == null) {
return null;
}
GroupCourseRecommend recommend = new GroupCourseRecommend();
BeanUtil.copyProperties(entity, recommend);
return recommend;
}
private GroupCourseRecommendEntity toEntity(GroupCourseRecommend domain) {
if (domain == null) {
return null;
}
GroupCourseRecommendEntity entity = new GroupCourseRecommendEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -6,6 +6,7 @@ import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import org.springframework.data.domain.Sort;
@@ -184,4 +185,27 @@ public class GroupCourseRepository implements IGroupCourseRepository {
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
.map(groupCourseConverter::toDomain);
}
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
return groupCourseDao.countSearchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
.flatMap(total -> {
if (total == 0) {
return Mono.just(new PageResponse<>(
List.of(), 0, 0L,
query.getPage() != null ? query.getPage() : 0,
query.getSize() != null ? query.getSize() : 10));
}
return groupCourseDao.searchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
.map(groupCourseConverter::toDomain)
.collectList()
.map(courseList -> {
int size = query.getSize() != null ? query.getSize() : 10;
int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(
courseList, totalPages, total,
query.getPage() != null ? query.getPage() : 0, size);
});
});
}
}
@@ -0,0 +1,28 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseRecommendService {
Mono<GroupCourseRecommend> findById(Long id);
Flux<GroupCourseRecommend> findAll();
Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder);
Flux<GroupCourseRecommend> findAllActive();
Flux<GroupCourseRecommend> findByCourseId(Long courseId);
Mono<GroupCourseRecommend> create(GroupCourseRecommend recommend);
Mono<GroupCourseRecommend> update(Long id, GroupCourseRecommend recommend);
Mono<Void> delete(Long id);
Mono<GroupCourseRecommend> enable(Long id);
Mono<GroupCourseRecommend> disable(Long id);
}
@@ -5,6 +5,7 @@ import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -25,4 +26,6 @@ public interface IGroupCourseService {
Mono<GroupCourse> signIn(Long courseId, Long memberId);
Mono<Void> delete(Long id);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
}
@@ -0,0 +1,142 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class GroupCourseRecommendService implements IGroupCourseRecommendService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecommendService.class);
private final IGroupCourseRecommendRepository recommendRepository;
private final IGroupCourseRepository groupCourseRepository;
public GroupCourseRecommendService(IGroupCourseRecommendRepository recommendRepository,
IGroupCourseRepository groupCourseRepository) {
this.recommendRepository = recommendRepository;
this.groupCourseRepository = groupCourseRepository;
}
@Override
public Mono<GroupCourseRecommend> findById(Long id) {
return recommendRepository.findById(id)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAll() {
return recommendRepository.findAll()
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder) {
return recommendRepository.findAll(sortBy, sortOrder)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAllActive() {
return recommendRepository.findAllActive()
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findByCourseId(Long courseId) {
return recommendRepository.findByCourseId(courseId)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Mono<GroupCourseRecommend> create(GroupCourseRecommend recommend) {
if (recommend.getCourseId() == null) {
return Mono.error(new RuntimeException("团课ID不能为空"));
}
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> {
return recommendRepository.save(recommend)
.doOnSuccess(r -> logger.info("团课推荐创建成功 - id={}, courseId={}", r.getId(), r.getCourseId()))
.doOnError(error -> logger.error("团课推荐创建失败 - error: {}", error.getMessage()));
});
}
@Override
public Mono<GroupCourseRecommend> update(Long id, GroupCourseRecommend recommend) {
return recommendRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.flatMap(existing -> {
if (recommend.getRecommendTitle() != null) {
existing.setRecommendTitle(recommend.getRecommendTitle());
}
if (recommend.getRecommendContent() != null) {
existing.setRecommendContent(recommend.getRecommendContent());
}
if (recommend.getRecommendReason() != null) {
existing.setRecommendReason(recommend.getRecommendReason());
}
if (recommend.getPriority() != null) {
existing.setPriority(recommend.getPriority());
}
if (recommend.getIsActive() != null) {
existing.setIsActive(recommend.getIsActive());
}
if (recommend.getCourseId() != null) {
existing.setCourseId(recommend.getCourseId());
}
return recommendRepository.update(existing);
})
.doOnSuccess(r -> logger.info("团课推荐更新成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return recommendRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.flatMap(recommend -> {
return recommendRepository.deleteById(id)
.doOnSuccess(v -> logger.info("团课推荐删除成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐删除失败 - id={}, error: {}", id, error.getMessage()));
});
}
@Override
public Mono<GroupCourseRecommend> enable(Long id) {
return recommendRepository.updateActiveStatus(id, true)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.doOnSuccess(r -> logger.info("团课推荐启用成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐启用失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<GroupCourseRecommend> disable(Long id) {
return recommendRepository.updateActiveStatus(id, false)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.doOnSuccess(r -> logger.info("团课推荐禁用成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐禁用失败 - id={}, error: {}", id, error.getMessage()));
}
private Mono<GroupCourseRecommend> fillGroupCourseInfo(GroupCourseRecommend recommend) {
if (recommend.getCourseId() == null) {
return Mono.just(recommend);
}
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.map(course -> {
recommend.setGroupCourse(course);
return recommend;
})
.defaultIfEmpty(recommend);
}
}
@@ -9,6 +9,7 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.enums.CourseEvent;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine;
@@ -17,6 +18,7 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepositor
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType;
@@ -26,11 +28,14 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Service
public class GroupCourseService implements IGroupCourseService {
@@ -45,6 +50,7 @@ public class GroupCourseService implements IGroupCourseService {
private final RedisUtil redisUtil;
private final ObjectMapper objectMapper;
private final GroupCourseStateMachine stateMachine;
private final DatabaseClient databaseClient;
private static final String CACHE_KEY_PREFIX = "group_course:page:";
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
@@ -61,7 +67,8 @@ public class GroupCourseService implements IGroupCourseService {
MemberCardRepository memberCardRepository,
RedisUtil redisUtil,
ObjectMapper objectMapper,
GroupCourseStateMachine stateMachine){
GroupCourseStateMachine stateMachine,
DatabaseClient databaseClient){
this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository;
this.groupCourseTypeRepository = groupCourseTypeRepository;
@@ -71,6 +78,7 @@ public class GroupCourseService implements IGroupCourseService {
this.redisUtil = redisUtil;
this.objectMapper = objectMapper;
this.stateMachine = stateMachine;
this.databaseClient = databaseClient;
}
@Override
@@ -149,6 +157,7 @@ public class GroupCourseService implements IGroupCourseService {
detail.setDescription(course.getDescription());
detail.setPointCardAmount(course.getPointCardAmount());
detail.setStoredValueAmount(course.getStoredValueAmount());
detail.setQrCodePath(course.getQrCodePath());
detail.setCreatedAt(course.getCreatedAt());
detail.setUpdatedAt(course.getUpdatedAt());
@@ -262,7 +271,38 @@ public class GroupCourseService implements IGroupCourseService {
@Override
public Mono<GroupCourse> create(GroupCourse groupCourse) {
return groupCourseRepository.save(groupCourse)
.doOnSuccess(course -> logger.info("团课创建成功 - id={}, name={}", course.getId(), course.getCourseName()))
.flatMap(course -> {
try {
// 生成二维码内容:团课基础信息JSON
Map<String, Object> qrCodeContent = new HashMap<>();
qrCodeContent.put("id", course.getId());
qrCodeContent.put("courseName", course.getCourseName());
qrCodeContent.put("coachId", course.getCoachId());
qrCodeContent.put("courseType", course.getCourseType());
qrCodeContent.put("startTime", course.getStartTime());
qrCodeContent.put("endTime", course.getEndTime());
qrCodeContent.put("maxMembers", course.getMaxMembers());
qrCodeContent.put("location", course.getLocation());
qrCodeContent.put("description", course.getDescription());
qrCodeContent.put("createdAt", course.getCreatedAt());
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码并上传到阿里云OSS
String qrCodeUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
course.setQrCodePath(qrCodeUrl);
logger.info("团课二维码上传到OSS成功 - id={}, qrCodeUrl={}", course.getId(), qrCodeUrl);
// 更新团课信息,保存二维码路径
return groupCourseRepository.update(course)
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
} catch (Exception e) {
logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e);
// 即使二维码生成失败,也返回成功创建的团课
return Mono.just(course);
}
})
.flatMap(course -> clearCache().thenReturn(course))
.doOnError(error -> logger.error("团课创建失败 - error: {}", error.getMessage()));
}
@@ -308,6 +348,9 @@ public class GroupCourseService implements IGroupCourseService {
if (groupCourse.getStoredValueAmount() != null) {
existing.setStoredValueAmount(groupCourse.getStoredValueAmount());
}
if (groupCourse.getQrCodePath() != null) {
existing.setQrCodePath(groupCourse.getQrCodePath());
}
return groupCourseRepository.update(existing);
})
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
@@ -440,26 +483,56 @@ public class GroupCourseService implements IGroupCourseService {
@Override
public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course -> {
if (course.getStatus() != 0L) {
return Mono.error(new RuntimeException("课程状态不允许签到"));
// 校验1:团课已取消
if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
return Mono.error(new RuntimeException("团课已取消,无法签到"));
}
// 校验2:团课已结束
if (course.getStatus().equals(CourseStatus.ENDED.getValue())) {
return Mono.error(new RuntimeException("团课已结束,无法签到"));
}
// 校验3:签到时间校验(开课前2小时内才能签到)
LocalDateTime now = LocalDateTime.now();
LocalDateTime signInStart = course.getStartTime().minusHours(2);
if (now.isBefore(signInStart)) {
return Mono.error(new RuntimeException("未到签到时间,请在开课前2小时内签到"));
}
if (now.isAfter(course.getEndTime())) {
return Mono.error(new RuntimeException("团课已结束,无法签到"));
}
// 校验4:课程已满员
if (course.getCurrentMembers() >= course.getMaxMembers()) {
return Mono.error(new RuntimeException("课程已满员"));
return Mono.error(new RuntimeException("课程已满员,无法签到"));
}
// 检查会员是否已预约此课程
return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("会员未预约此课程")))
.flatMap(booking -> {
// 更新课程当前人数
return groupCourseRepository.updateCurrentMembers(courseId, 1)
.flatMap(updatedCourse -> {
// 更新预约状态为已出席
return bookingRepository.updateStatus(booking.getId(), "2")
.thenReturn(updatedCourse);
// 校验5:用户今日是否已到店签到(直接查询sign_in_record表)
LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
return databaseClient.sql("SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false ORDER BY sign_in_time DESC LIMIT 1")
.bind("memberId", memberId)
.bind("startTime", todayStart)
.bind("endTime", todayEnd)
.map(row -> row.get("sign_in_status", String.class))
.one()
.switchIfEmpty(Mono.error(new RuntimeException("请先完成到店签到")))
.flatMap(status -> {
if (!"SUCCESS".equals(status)) {
return Mono.error(new RuntimeException("到店签到未成功,请重新签到"));
}
// 校验6:用户已预约此课程(有效预约,状态为0-已预约)
return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
.flatMap(booking -> {
return groupCourseRepository.updateCurrentMembers(courseId, 1)
.flatMap(updatedCourse -> {
return bookingRepository.updateStatus(booking.getId(), "2")
.thenReturn(updatedCourse);
});
});
});
})
@@ -488,6 +561,18 @@ public class GroupCourseService implements IGroupCourseService {
});
}
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
query.getCourseName(), query.getCourseType(), query.getStartDate(), query.getEndDate(),
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
return groupCourseRepository.searchGroupCourses(query)
.doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}",
result.getTotalElements(), result.getCurrentPage(), result.getPageSize()))
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
}
private Mono<Void> clearCache() {
return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*")
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
@@ -0,0 +1,109 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.PutObjectRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 阿里云OSS工具类
*/
public class OSSUtil {
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
// OSS配置信息
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY_ID = "LTAI5t9TFh9Vayeahz45kZjg";
private static final String ACCESS_KEY_SECRET = "zD6NlCeH5UhjBs4vnQVqn8Ksi3CaZz";
private static final String BUCKET_NAME = "ycc-filesaver";
// OSS访问地址前缀
private static final String OSS_URL_PREFIX = "https://" + BUCKET_NAME + "." + ENDPOINT + "/";
// 文件存储目录
private static final String QRCODE_DIR = "qrcode/";
/**
* 上传文件到阿里云OSS
*
* @param localFilePath 本地文件路径
* @param fileName 文件名(不含路径)
* @return OSS访问地址
*/
public static String uploadToOSS(String localFilePath, String fileName) {
OSS ossClient = null;
try {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
// 构建OSS文件路径:qrcode/2026/06/18/xxx.png
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
// 上传文件
ossClient.putObject(putObjectRequest);
// 构建访问地址
String accessUrl = OSS_URL_PREFIX + ossFilePath;
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
return accessUrl;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 上传文件到阿里云OSS(自定义存储路径)
*
* @param localFilePath 本地文件路径
* @param ossDirectory OSS存储目录
* @param fileName 文件名(不含路径)
* @return OSS访问地址
*/
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
OSS ossClient = null;
try {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
// 构建OSS文件路径
String ossFilePath = ossDirectory + fileName;
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
// 上传文件
ossClient.putObject(putObjectRequest);
// 构建访问地址
String accessUrl = OSS_URL_PREFIX + ossFilePath;
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
return accessUrl;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
}
@@ -0,0 +1,156 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 二维码生成工具类
*/
public class QRCodeUtil {
private static final Logger logger = LoggerFactory.getLogger(QRCodeUtil.class);
// 二维码默认保存路径(本地临时路径)
private static final String DEFAULT_SAVE_PATH = "D:\\Games\\exmp\\image";
// 二维码尺寸
private static final int QR_CODE_WIDTH = 300;
private static final int QR_CODE_HEIGHT = 300;
/**
* 生成二维码并保存到指定路径
*
* @param content 二维码内容
* @param savePath 保存路径
* @param fileName 文件名(不含扩展名)
* @return 生成的二维码文件完整路径
*/
public static String generateQRCode(String content, String savePath, String fileName) {
try {
Path directory = Paths.get(savePath);
if (!Files.exists(directory)) {
Files.createDirectories(directory);
logger.info("创建二维码保存目录: {}", savePath);
}
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hints.put(EncodeHintType.MARGIN, 1);
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
String filePath = Paths.get(savePath, fileName + ".png").toString();
Path path = Paths.get(filePath);
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", path);
logger.info("二维码生成成功: {}", filePath);
return filePath;
} catch (WriterException e) {
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
} catch (IOException e) {
logger.error("二维码保存失败 - IOException: {}", e.getMessage(), e);
throw new RuntimeException("二维码保存失败: " + e.getMessage(), e);
}
}
/**
* 生成二维码并保存到默认路径
* 文件名格式: UUID + 创建时间
*
* @param content 二维码内容
* @return 生成的二维码文件完整路径
*/
public static String generateQRCode(String content) {
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = uuid + "_" + timestamp;
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
}
/**
* 生成二维码并保存到默认路径,使用自定义文件名
*
* @param content 二维码内容
* @param fileName 文件名(不含扩展名)
* @return 生成的二维码文件完整路径
*/
public static String generateQRCodeWithFileName(String content, String fileName) {
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
}
/**
* 生成二维码并上传到阿里云OSS
* 文件名格式: UUID + 创建时间
*
* @param content 二维码内容
* @return 阿里云OSS访问地址
*/
public static String generateQRCodeAndUploadToOSS(String content) {
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = uuid + "_" + timestamp + ".png";
return generateQRCodeAndUploadToOSS(content, fileName);
}
/**
* 生成二维码并上传到阿里云OSS
*
* @param content 二维码内容
* @param fileName 文件名(含扩展名)
* @return 阿里云OSS访问地址
*/
public static String generateQRCodeAndUploadToOSS(String content, String fileName) {
try {
Path tempDir = Files.createTempDirectory("qrcode_temp");
String tempFilePath = tempDir.resolve(fileName).toString();
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hints.put(EncodeHintType.MARGIN, 1);
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", Paths.get(tempFilePath));
logger.info("二维码临时文件生成成功: {}", tempFilePath);
String ossUrl = OSSUtil.uploadToOSS(tempFilePath, fileName);
Files.deleteIfExists(Paths.get(tempFilePath));
Files.deleteIfExists(tempDir);
logger.info("临时文件已删除: {}", tempFilePath);
return ossUrl;
} catch (WriterException e) {
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
} catch (IOException e) {
logger.error("二维码处理失败 - IOException: {}", e.getMessage(), e);
throw new RuntimeException("二维码处理失败: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,71 @@
package cn.novalon.gym.manage.groupcourse.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/**
* QRCodeUtil测试类
*/
class QRCodeUtilTest {
@TempDir
Path tempDir;
@Test
void testGenerateQRCode() {
String content = "测试二维码内容";
String qrCodePath = QRCodeUtil.generateQRCode(content);
assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
assertTrue(qrCodePath.contains("D:\\Games\\exmp\\image"), "二维码应保存到指定路径");
System.out.println("生成的二维码路径: " + qrCodePath);
}
@Test
void testGenerateQRCodeWithCustomPath() {
String content = "自定义路径测试";
String customPath = tempDir.toString();
String fileName = "test_qrcode";
String qrCodePath = QRCodeUtil.generateQRCode(content, customPath, fileName);
assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
assertTrue(qrCodePath.contains(fileName), "二维码文件名应包含指定名称");
System.out.println("生成的二维码路径: " + qrCodePath);
}
@Test
void testGenerateQRCodeWithJsonContent() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
String qrCodePath = QRCodeUtil.generateQRCode(jsonContent);
assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
System.out.println("JSON内容二维码路径: " + qrCodePath);
}
@Test
void testGenerateQRCodeAndUploadToOSS() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
assertNotNull(ossUrl, "OSS访问地址不应为空");
assertTrue(ossUrl.startsWith("https://"), "OSS访问地址应为HTTPS");
assertTrue(ossUrl.contains("ycc-filesaver.oss-cn-beijing.aliyuncs.com"), "OSS访问地址应包含正确的域名");
assertTrue(ossUrl.contains("/qrcode/"), "OSS访问地址应包含qrcode目录");
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
System.out.println("上传到OSS的二维码地址: " + ossUrl);
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
<Match>
<Class name="~.*\.entity\..*" />
</Match>
<Match>
<Class name="~.*\.dto\..*" />
</Match>
<Match>
<Class name="~.*\.converter\..*" />
</Match>
</FindBugsFilter>
@@ -71,6 +71,22 @@ public class Member extends BaseEntity {
@Column("official_open_id")
private String officialOpenId;
// 阿里云号码认证OpenID
@Column("dypns_open_id")
private String dypnsOpenId;
// 身份证号码(AES加密存储)
@Column("id_card")
private String idCard;
// 真实姓名(AES加密存储)
@Column("real_name")
private String realName;
// 注册渠道:SMS-短信验证码,ONE_CLICK-一键登录,WECHAT-微信授权
@Column("register_channel")
private String registerChannel;
// 软删除
@Column("is_deleted")
private Boolean isDeleted;
@@ -32,6 +32,7 @@ public class MemberCardRecordHandler {
@Operation(summary = "购买会员卡", description = "支持时长卡、次卡、储值卡,自动设置到期提醒")
public Mono<ServerResponse> purchaseCard(ServerRequest request) {
return request.bodyToMono(PurchaseRequest.class)
.flatMap(body -> memberCardService.purchaseCard(
body.getMemberId(),
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.common.exception.NotFoundException;
import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto;
import cn.novalon.gym.manage.member.dto.SearchMemberDto;
@@ -16,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -50,7 +52,11 @@ public class MemberHandler {
return memberService.getMemberInfo(memberId)
.flatMap(info -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(info));
.bodyValue(info))
.onErrorResume(NotFoundException.class, e ->
ServerResponse.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(java.util.Map.of("code", 404, "message", e.getMessage())));
}
@Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息")
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.member.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.entity.MemberCardTransaction;
@@ -15,7 +16,6 @@ import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.service.IMemberCardService;
import cn.novalon.gym.manage.member.service.IMemberCardTransactionService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@@ -65,21 +65,37 @@ public class MemberServiceImpl implements MemberService {
public Mono<MemberInfoVO> getMemberInfo(Long memberId) {
String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
// 先查缓存
return redisUtil.get(cacheKey, MemberInfoVO.class)
.flatMap(cached -> {
if (cached != null) {
log.debug("从缓存获取会员信息, memberId: {}", memberId);
return Mono.just(cached);
}
return memberRepository.findById(memberId)
.map(this::buildMemberInfoResponse)
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo)))
.switchIfEmpty(Mono.error(() -> {
log.error("会员不存在: memberId={}", memberId);
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}));
});
// 缓存没有,查数据库
return queryFromDatabaseAndCache(memberId, cacheKey);
})
.switchIfEmpty(Mono.defer(() -> {
// 缓存返回null,查数据库
return queryFromDatabaseAndCache(memberId, cacheKey);
}));
}
/**
* 从数据库查询并更新缓存
*/
private Mono<MemberInfoVO> queryFromDatabaseAndCache(Long memberId, String cacheKey) {
return memberRepository.findById(memberId)
.map(this::buildMemberInfoResponse)
.flatMap(vo -> {
// 查询到数据后更新缓存
return redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo));
})
.switchIfEmpty(Mono.error(() -> {
log.error("会员不存在: memberId={}", memberId);
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}));
}
@Override
+387 -223
View File
@@ -1,223 +1,387 @@
2026-06-11T13:43:44.371+08:00 TRACE 9324 --- [gym-manage-api] [nio-8084-exec-2] o.s.w.s.adapter.HttpWebHandlerAdapter : [27afd417] HTTP POST "/api/groupCourse/types/1/labels", headers={masked}
2026-06-11T13:43:44.372+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.w.s.s.DefaultWebSessionManager : Created new WebSession.
2026-06-11T13:43:44.372+08:00 INFO 9324 --- [gym-manage-api] [ parallel-2] c.n.g.m.sys.audit.OperationLogWebFilter : WebFilter 拦截请求: POST /api/groupCourse/types/1/labels
2026-06-11T13:43:44.372+08:00 INFO 9324 --- [gym-manage-api] [ parallel-2] c.n.g.m.sys.audit.OperationLogWebFilter : 未匹配到操作日志配置,跳过: POST /api/groupCourse/types/1/labels
2026-06-11T13:43:44.372+08:00 INFO 9324 --- [gym-manage-api] [ parallel-2] c.n.g.m.sys.audit.OperationLogWebFilter : WebFilter 拦截请求: POST /api/groupCourse/types/1/labels
2026-06-11T13:43:44.372+08:00 INFO 9324 --- [gym-manage-api] [ parallel-2] c.n.g.m.sys.audit.OperationLogWebFilter : 未匹配到操作日志配置,跳过: POST /api/groupCourse/types/1/labels
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/dictionaries" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/{id}/action/change-password" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/{id}/action/logical-delete" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/logical-delete" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/action/restore" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/{id}/action/restore" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/{id}/roles" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/menus" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/roles" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/roles/{id}/restore" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/roles/{id}/permissions" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/config" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/logs/login" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/logs/exception" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/logs/operation" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/auth/login" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/auth/register" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/auth/logout" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/dict/types" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/dict/data" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/notices" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/messages" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/files/upload" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/permissions" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member/auth/miniapp/login" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member/auth/mp/callback" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member/phone/bind" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/admin/member/{id}/phone" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-cards" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/purchase" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/{recordId}/renew" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/{recordId}/use" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/{recordId}/refund" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/process-expired" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-transactions" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/groupCourse/page" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/groupCourse/types" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/groupCourse/labels" does not match against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/groupCourse/types/{typeId}/labels" matches against value "/api/groupCourse/types/1/labels"
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.function.server.RouterFunctions : [27afd417] Matched (POST && /api/groupCourse/types/{typeId}/labels)
2026-06-11T13:43:44.376+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.s.s.RouterFunctionMapping : [27afd417] Mapped to cn.novalon.gym.manage.app.config.SystemRouter$$Lambda/0x00000223a09d1920@3760f3e8
2026-06-11T13:43:44.377+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] org.springframework.web.HttpLogging : [27afd417] Decoded [{labelIds=[1, 3, 5]}]
2026-06-11T13:43:44.377+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.r.c.R2dbcTransactionManager : Creating new transaction with name [cn.novalon.gym.manage.groupcourse.repository.impl.CourseLabelRepository.addLabelsToType]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2026-06-11T13:43:44.377+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.r.c.R2dbcTransactionManager : Acquired Connection [PooledConnection[PostgresqlConnection{client=io.r2dbc.postgresql.client.ReactorNettyClient@c3a5202, codecs=io.r2dbc.postgresql.codec.DefaultCodecs@922d1d4}]] for R2DBC transaction
2026-06-11T13:43:44.377+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.r.c.R2dbcTransactionManager : Starting R2DBC transaction on Connection [PooledConnection[PostgresqlConnection{client=io.r2dbc.postgresql.client.ReactorNettyClient@c3a5202, codecs=io.r2dbc.postgresql.codec.DefaultCodecs@922d1d4}]] using [ExtendedTransactionDefinition [transactionName='cn.novalon.gym.manage.groupcourse.repository.impl.CourseLabelRepository.addLabelsToType', readOnly=false, isolationLevel=null, lockWaitTimeout=PT0S]]
2026-06-11T13:43:44.379+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [SELECT course_type_label.type_id, course_type_label.label_id, course_type_label.id, course_type_label.create_by, course_type_label.update_by, course_type_label.created_at, course_type_label.updated_at, course_type_label.deleted_at FROM course_type_label WHERE course_type_label.type_id = $1 AND (course_type_label.label_id = $2) AND (course_type_label.deleted_at IS NULL)]
2026-06-11T13:43:44.381+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [SELECT course_type_label.type_id, course_type_label.label_id, course_type_label.id, course_type_label.create_by, course_type_label.update_by, course_type_label.created_at, course_type_label.updated_at, course_type_label.deleted_at FROM course_type_label WHERE course_type_label.type_id = $1 AND (course_type_label.label_id = $2) AND (course_type_label.deleted_at IS NULL)]
2026-06-11T13:43:44.382+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [SELECT course_type_label.type_id, course_type_label.label_id, course_type_label.id, course_type_label.create_by, course_type_label.update_by, course_type_label.created_at, course_type_label.updated_at, course_type_label.deleted_at FROM course_type_label WHERE course_type_label.type_id = $1 AND (course_type_label.label_id = $2) AND (course_type_label.deleted_at IS NULL)]
2026-06-11T13:43:44.382+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]
2026-06-11T13:43:44.385+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]
2026-06-11T13:43:44.385+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]
2026-06-11T13:43:44.387+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r.c.R2dbcTransactionManager : Initiating transaction rollback
2026-06-11T13:43:44.387+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r.c.R2dbcTransactionManager : Rolling back R2DBC transaction on Connection [PooledConnection[PostgresqlConnection{client=io.r2dbc.postgresql.client.ReactorNettyClient@c3a5202, codecs=io.r2dbc.postgresql.codec.DefaultCodecs@922d1d4}]]
2026-06-11T13:43:44.389+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r.c.R2dbcTransactionManager : Releasing R2DBC Connection [PooledConnection[PostgresqlConnection{client=io.r2dbc.postgresql.client.ReactorNettyClient@c3a5202, codecs=io.r2dbc.postgresql.codec.DefaultCodecs@922d1d4}]] after transaction
2026-06-11T13:43:44.389+08:00 ERROR 9324 --- [gym-manage-api] [actor-tcp-nio-6] c.n.g.m.g.s.impl.CourseLabelService : 标签添加到类型失败 - typeId=1, error: executeMany; SQL [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]; 重复键违反唯一约束"course_type_label_type_id_label_id_key"
2026-06-11T13:43:44.389+08:00 TRACE 9324 --- [gym-manage-api] [actor-tcp-nio-6] org.springframework.web.HttpLogging : [27afd417] Encoding [{success=false, message=executeMany; SQL [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]; 重复键违反唯一约束"course_type_label_type_id_label_id_key"}]
2026-06-11T13:43:44.390+08:00 TRACE 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.w.s.adapter.HttpWebHandlerAdapter : [27afd417] Completed 400 BAD_REQUEST, headers={masked}
2026-06-11T13:43:44.390+08:00 TRACE 9324 --- [gym-manage-api] [actor-tcp-nio-6] org.springframework.web.HttpLogging : [27afd417] onComplete
[
{
"id": "3",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00",
"deletedAt": null,
"courseId": "1",
"recommendTitle": "本周热门推荐",
"recommendContent": "极速燃脂单车课程,跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡!",
"recommendReason": "教练专业,课程内容丰富,深受学员喜爱,燃脂效果显著",
"priority": 20,
"isActive": true,
"groupCourse": {
"id": "1",
"createBy": "system",
"updateBy": "system",
"createdAt": "2026-06-11T13:50:25.118925",
"updatedAt": "2026-06-11T13:50:25.118925",
"deletedAt": null,
"courseName": "动感单车升级版aaaaa",
"coachId": "2",
"courseType": "2",
"startTime": "2026-06-02T16:45:00",
"endTime": "2026-06-15T20:20:00",
"maxMembers": 30,
"currentMembers": 0,
"status": "0",
"location": "单车房",
"coverImage": "/images/spinning.jpg",
"description": "升级版高强度有氧运动课程",
"pointCardAmount": 2,
"storedValueAmount": 80
}
},
{
"id": "7",
"createBy": "coach_li",
"updateBy": null,
"createdAt": "2026-05-25T09:15:00",
"updatedAt": "2026-05-25T09:15:00",
"deletedAt": null,
"courseId": "6",
"recommendTitle": "塑形热门课程",
"recommendContent": "蜜桃臀塑造课程,针对性训练臀部肌肉群,打造完美曲线。",
"recommendReason": "专业私教指导,动作标准,效果显著,深受女性学员喜爱",
"priority": 18,
"isActive": true,
"groupCourse": {
"id": "6",
"createBy": "coach_li",
"updateBy": null,
"createdAt": "2026-05-20T09:15:00",
"updatedAt": "2026-05-20T09:15:00",
"deletedAt": null,
"courseName": "蜜桃臀塑造",
"coachId": "103",
"courseType": "3",
"startTime": "2026-05-30T19:00:00",
"endTime": "2026-05-30T20:00:00",
"maxMembers": 10,
"currentMembers": 8,
"status": "2",
"location": "私教专区",
"coverImage": "/images/glute.jpg",
"description": "针对性训练臀部肌肉群,课程已于5月30日结束,无法预约。",
"pointCardAmount": 1,
"storedValueAmount": 0
}
},
{
"id": "9",
"createBy": "coach_zhang",
"updateBy": null,
"createdAt": "2026-06-15T14:00:00",
"updatedAt": "2026-06-15T14:00:00",
"deletedAt": null,
"courseId": "1",
"recommendTitle": "减脂首选课程",
"recommendContent": "想要快速减脂?极速燃脂单车是你的最佳选择!专业教练带领,科学训练计划。",
"recommendReason": "减脂效果最佳,课程强度适中,适合想要快速瘦身的学员",
"priority": 16,
"isActive": true,
"groupCourse": {
"id": "1",
"createBy": "system",
"updateBy": "system",
"createdAt": "2026-06-11T13:50:25.118925",
"updatedAt": "2026-06-11T13:50:25.118925",
"deletedAt": null,
"courseName": "动感单车升级版aaaaa",
"coachId": "2",
"courseType": "2",
"startTime": "2026-06-02T16:45:00",
"endTime": "2026-06-15T20:20:00",
"maxMembers": 30,
"currentMembers": 0,
"status": "0",
"location": "单车房",
"coverImage": "/images/spinning.jpg",
"description": "升级版高强度有氧运动课程",
"pointCardAmount": 2,
"storedValueAmount": 80
}
},
{
"id": "4",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-15T11:00:00",
"updatedAt": "2026-06-15T11:00:00",
"deletedAt": null,
"courseId": "2",
"recommendTitle": "新手友好推荐",
"recommendContent": "清晨流瑜伽课程,适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。",
"recommendReason": "适合新手入门,教练耐心指导,课程节奏适中",
"priority": 15,
"isActive": true,
"groupCourse": {
"id": "2",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00",
"deletedAt": null,
"courseName": "清晨流瑜伽",
"coachId": "101",
"courseType": "1",
"startTime": "2026-06-12T09:00:00",
"endTime": "2026-06-12T10:30:00",
"maxMembers": 15,
"currentMembers": 5,
"status": "0",
"location": "A座3楼瑜伽教室",
"coverImage": "/images/yoga_flow.jpg",
"description": "适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。",
"pointCardAmount": 1,
"storedValueAmount": 0
}
},
{
"id": "10",
"createBy": "coach_wang",
"updateBy": null,
"createdAt": "2026-06-15T15:00:00",
"updatedAt": "2026-06-15T15:00:00",
"deletedAt": null,
"courseId": "2",
"recommendTitle": "晨练优选",
"recommendContent": "清晨流瑜伽,唤醒身体能量,开启活力一天!适合晨练爱好者。",
"recommendReason": "晨练最佳选择,提升身体活力,改善精神状态",
"priority": 14,
"isActive": true,
"groupCourse": {
"id": "2",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00",
"deletedAt": null,
"courseName": "清晨流瑜伽",
"coachId": "101",
"courseType": "1",
"startTime": "2026-06-12T09:00:00",
"endTime": "2026-06-12T10:30:00",
"maxMembers": 15,
"currentMembers": 5,
"status": "0",
"location": "A座3楼瑜伽教室",
"coverImage": "/images/yoga_flow.jpg",
"description": "适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。",
"pointCardAmount": 1,
"storedValueAmount": 0
}
},
{
"id": "6",
"createBy": "coach_li",
"updateBy": null,
"createdAt": "2026-06-15T13:00:00",
"updatedAt": "2026-06-15T13:00:00",
"deletedAt": null,
"courseId": "4",
"recommendTitle": "基础瑜伽推荐",
"recommendContent": "基础哈他瑜伽课程,适合所有级别学员,通过基础体式练习提升身体柔韧性和平衡能力。",
"recommendReason": "零基础友好,适合所有健身水平,放松身心",
"priority": 12,
"isActive": true,
"groupCourse": {
"id": "4",
"createBy": "coach_li",
"updateBy": null,
"createdAt": "2026-06-01T08:00:00",
"updatedAt": "2026-06-01T08:00:00",
"deletedAt": null,
"courseName": "哈他瑜伽",
"coachId": "101",
"courseType": "1",
"startTime": "2026-06-01T15:20:00",
"endTime": "2026-06-01T16:50:00",
"maxMembers": 12,
"currentMembers": 3,
"status": "0",
"location": "瑜伽教室B",
"coverImage": "/images/hatha_yoga.jpg",
"description": "基础哈他瑜伽,适合所有级别。距开始不足30分钟,已停止预约。",
"pointCardAmount": 1,
"storedValueAmount": 0
}
},
{
"id": "11",
"createBy": "coach_li",
"updateBy": null,
"createdAt": "2026-06-15T16:00:00",
"updatedAt": "2026-06-15T16:00:00",
"deletedAt": null,
"courseId": "4",
"recommendTitle": "身心平衡推荐",
"recommendContent": "哈他瑜伽课程,通过体式练习和呼吸控制,达到身心平衡,提升整体健康水平。",
"recommendReason": "改善身体柔韧性,增强核心力量,提升身体协调性",
"priority": 11,
"isActive": true,
"groupCourse": {
"id": "4",
"createBy": "coach_li",
"updateBy": null,
"createdAt": "2026-06-01T08:00:00",
"updatedAt": "2026-06-01T08:00:00",
"deletedAt": null,
"courseName": "哈他瑜伽",
"coachId": "101",
"courseType": "1",
"startTime": "2026-06-01T15:20:00",
"endTime": "2026-06-01T16:50:00",
"maxMembers": 12,
"currentMembers": 3,
"status": "0",
"location": "瑜伽教室B",
"coverImage": "/images/hatha_yoga.jpg",
"description": "基础哈他瑜伽,适合所有级别。距开始不足30分钟,已停止预约。",
"pointCardAmount": 1,
"storedValueAmount": 0
}
},
{
"id": "1",
"createBy": "system",
"updateBy": "system",
"createdAt": "2026-06-15T16:21:20.865146",
"updatedAt": "2026-06-15T16:23:05.180219",
"deletedAt": null,
"courseId": "1",
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": false,
"groupCourse": {
"id": "1",
"createBy": "system",
"updateBy": "system",
"createdAt": "2026-06-11T13:50:25.118925",
"updatedAt": "2026-06-11T13:50:25.118925",
"deletedAt": null,
"courseName": "动感单车升级版aaaaa",
"coachId": "2",
"courseType": "2",
"startTime": "2026-06-02T16:45:00",
"endTime": "2026-06-15T20:20:00",
"maxMembers": 30,
"currentMembers": 0,
"status": "0",
"location": "单车房",
"coverImage": "/images/spinning.jpg",
"description": "升级版高强度有氧运动课程",
"pointCardAmount": 2,
"storedValueAmount": 80
}
},
{
"id": "5",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-15T12:00:00",
"updatedAt": "2026-06-15T12:00:00",
"deletedAt": null,
"courseId": "3",
"recommendTitle": "高强度燃脂",
"recommendContent": "燃脂搏击课程,高强度间歇训练,配合音乐快速燃脂,释放压力。",
"recommendReason": "高强度训练,适合进阶学员,快速燃脂塑形",
"priority": 10,
"isActive": false,
"groupCourse": {
"id": "3",
"createBy": "coach_zhang",
"updateBy": null,
"createdAt": "2026-06-01T14:30:00",
"updatedAt": "2026-06-01T14:30:00",
"deletedAt": null,
"courseName": "燃脂搏击",
"coachId": "102",
"courseType": "2",
"startTime": "2026-06-10T18:30:00",
"endTime": "2026-06-10T19:30:00",
"maxMembers": 20,
"currentMembers": 20,
"status": "0",
"location": "综合训练区",
"coverImage": "/images/kickboxing.jpg",
"description": "高强度间歇训练,配合音乐快速燃脂,释放压力。名额已满,无法预约。",
"pointCardAmount": 1,
"storedValueAmount": 0
}
},
{
"id": "12",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-05-25T10:00:00",
"updatedAt": "2026-05-25T10:00:00",
"deletedAt": null,
"courseId": "7",
"recommendTitle": "职场减压课程",
"recommendContent": "午间冥想放松,专为职场人士设计,快速缓解工作压力,提升工作状态。",
"recommendReason": "职场减压首选,课程时间短,效果显著",
"priority": 9,
"isActive": false,
"groupCourse": {
"id": "7",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-05-25T09:00:00",
"updatedAt": "2026-05-25T09:00:00",
"deletedAt": null,
"courseName": "午间冥想放松",
"coachId": "101",
"courseType": "1",
"startTime": "2026-05-31T12:00:00",
"endTime": "2026-05-31T13:00:00",
"maxMembers": 15,
"currentMembers": 6,
"status": "2",
"location": "冥想室",
"coverImage": "/images/meditation_noon.jpg",
"description": "午间冥想课程,已于5月31日结束。",
"pointCardAmount": 1,
"storedValueAmount": 0
}
},
{
"id": "8",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-05-25T09:00:00",
"updatedAt": "2026-05-25T09:00:00",
"deletedAt": null,
"courseId": "7",
"recommendTitle": "午间放松推荐",
"recommendContent": "午间冥想放松课程,通过呼吸和正念冥想,深度放松身心,缓解工作压力。",
"recommendReason": "适合上班族,午间放松充电,提升下午工作效率",
"priority": 8,
"isActive": true,
"groupCourse": {
"id": "7",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-05-25T09:00:00",
"updatedAt": "2026-05-25T09:00:00",
"deletedAt": null,
"courseName": "午间冥想放松",
"coachId": "101",
"courseType": "1",
"startTime": "2026-05-31T12:00:00",
"endTime": "2026-05-31T13:00:00",
"maxMembers": 15,
"currentMembers": 6,
"status": "2",
"location": "冥想室",
"coverImage": "/images/meditation_noon.jpg",
"description": "午间冥想课程,已于5月31日结束。",
"pointCardAmount": 1,
"storedValueAmount": 0
}
}
]
+6 -1
View File
@@ -53,7 +53,12 @@
<artifactId>gym-dataCount</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-auth</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
@@ -10,14 +10,13 @@ import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDeta
import org.springframework.context.annotation.Bean;
import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories;
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.server.WebFilter;
import java.util.List;
@SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = {
ReactiveUserDetailsServiceAutoConfiguration.class })
@EnableScheduling
//@EnableScheduling
@EnableR2dbcRepositories(basePackages = {
"cn.novalon.gym.manage.db.dao",
"cn.novalon.gym.manage.sys.audit.repository" ,
@@ -1,11 +1,14 @@
package cn.novalon.gym.manage.app.config;
import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler;
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;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler;
import cn.novalon.gym.manage.member.handler.MemberCardHandler;
@@ -71,10 +74,12 @@ public class SystemRouter {
MemberCardTransactionHandler memberCardTransactionHandler,
GroupCourseHandler groupCourseHandler,
GroupCourseBookingHandler groupCourseBookingHandler,
GroupCourseRecommendHandler groupCourseRecommendHandler,
GroupCourseTypeHandler groupCourseTypeHandler,
CourseLabelHandler courseLabelHandler,
CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler) {
DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler) {
return route()
// ========== 诊断路由 ==========
@@ -190,10 +195,13 @@ public class SystemRouter {
// ========== 消息路由 ==========
.GET("/api/messages/user/{userId}", messageHandler::getMessagesByUser)
.GET("/api/messages/user/{userId}/page", messageHandler::getMessagesByUserPage)
.GET("/api/messages/user/{userId}/unread", messageHandler::getUnreadCount)
.GET("/api/messages/user/{userId}/unread/list", messageHandler::getUnreadList)
.GET("/api/messages/user/{userId}/unread/page", messageHandler::getUnreadMessagesPage)
.POST("/api/messages", messageHandler::createMessage)
.PUT("/api/messages/{id}/read", messageHandler::markAsRead)
.PUT("/api/messages/user/{userId}/read", messageHandler::markAllAsRead)
.DELETE("/api/messages/{id}", messageHandler::deleteMessage)
// ========== 文件路由 ==========
@@ -216,6 +224,11 @@ public class SystemRouter {
.PUT("/api/permissions/{id}", permissionHandler::updatePermission)
.DELETE("/api/permissions/{id}", permissionHandler::deletePermission)
// ========== 手机号认证路由 ==========
.POST("/api/auth/phone/one-click-login", phoneAuthHandler::oneClickLogin)
.POST("/api/auth/phone/send-code", phoneAuthHandler::sendSmsCode)
.POST("/api/auth/phone/code-login", phoneAuthHandler::codeLogin)
// ========== 会员模块路由 - 微信认证 ==========
.POST("/api/member/auth/miniapp/login", wechatAuthHandler::miniappLogin)
.GET("/api/member/auth/mp/callback", wechatAuthHandler::verifyMpSignature)
@@ -299,6 +312,17 @@ public class SystemRouter {
.GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId)
.GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById)
// ===== 团课推荐管理 =====
.GET("/api/groupCourse/recommend/list", groupCourseRecommendHandler::getAllRecommendations)
.GET("/api/groupCourse/recommend/active", groupCourseRecommendHandler::getAllActiveRecommendations)
.GET("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::getRecommendationById)
.GET("/api/groupCourse/recommend/course/{courseId}", groupCourseRecommendHandler::getRecommendationsByCourseId)
.POST("/api/groupCourse/recommend", groupCourseRecommendHandler::createRecommendation)
.PUT("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::updateRecommendation)
.DELETE("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::deleteRecommendation)
.POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation)
.POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation)
// ===== 团课课程管理(需要放在具体路由之后)=====
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById)
@@ -306,7 +330,8 @@ public class SystemRouter {
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.POST("/api/groupCourse/{courseId}/signin", groupCourseHandler::signIn)
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
// ========= 签到模块路由 ==========
// ===== 签到核心功能 =====
@@ -335,6 +360,7 @@ public class SystemRouter {
.GET("/api/datacount/signin", dataStatisticsHandler::getSignInStatistics)
.GET("/api/datacount/history", dataStatisticsHandler::queryHistoricalStatistics)
.GET("/api/datacount/export", dataStatisticsHandler::exportStatistics)
.build();
}
}
@@ -2,9 +2,9 @@ spring:
cache:
type: none
r2dbc:
url: r2dbc:postgresql://localhost:55432/manage_system
username: novalon
password: novalon123
url: r2dbc:postgresql://localhost:5432/manage_system
username: postgres
password: 123456
pool:
initial-size: 5
max-size: 20
@@ -12,10 +12,10 @@ spring:
max-life-time: 30m
acquire-timeout: 3s
flyway:
url: jdbc:postgresql://localhost:55432/manage_system
user: novalon
password: novalon123
enabled: true
url: jdbc:postgresql://localhost:5432/manage_system
user: postgres
password: 123456
enabled: false
locations: classpath:db/migration
baseline-on-migrate: true
validate-on-migrate: true
@@ -36,3 +36,11 @@ logging:
cn.novalon.manage: DEBUG
org.springframework.r2dbc: DEBUG
org.springframework.web: TRACE
alibaba:
cloud:
sms:
access-key-id: LTAI5t8GhorWLu5WkEx8MDZz
access-key-secret: jNDwb9IHvTIESUezLYHZRT5c5NEaCz
sign-name: 云渚科技验证平台
template-code: 100001
@@ -4,8 +4,8 @@ spring:
activate:
on-profile: local
r2dbc:
url: r2dbc:postgresql://localhost:55432/manage_system
username: novalon
url: r2dbc:postgresql://localhost:5432/manage_system
username: postgres
password: 123456
pool:
initial-size: 5
@@ -14,8 +14,8 @@ spring:
max-life-time: 30m
acquire-timeout: 3s
datasource:
url: jdbc:postgresql://localhost:55432/manage_system
username: novalon
url: jdbc:postgresql://localhost:5432/manage_system
username: postgres
password: 123456
driver-class-name: org.postgresql.Driver
flyway:
@@ -5,9 +5,9 @@ spring:
application:
name: manage-app
r2dbc:
url: r2dbc:postgresql://localhost:55432/manage_system
username: novalon
password: novalon123
url: r2dbc:postgresql://localhost:5432/manage_system
username: postgres
password: 123456
pool:
initial-size: 5
max-size: 20
@@ -15,9 +15,9 @@ spring:
exclude:
- org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
r2dbc:
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
username: ${DB_USERNAME:novalon}
password: ${DB_PASSWORD:novalon123}
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:manage_system}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:123456}
pool:
initial-size: 10
max-size: 50
@@ -25,12 +25,12 @@ spring:
max-life-time: 1h
acquire-timeout: 5s
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
username: ${DB_USERNAME:novalon}
password: ${DB_PASSWORD:novalon123}
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:manage_system}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:123456}
driver-class-name: org.postgresql.Driver
flyway:
enabled: true
enabled: false
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0
@@ -54,7 +54,7 @@ spring:
profiles:
active: dev
config:
import: classpath:member-config.yml
import: classpath:keys.properties,classpath:member-config.yml
@@ -94,3 +94,13 @@ springdoc:
show-actuator: false
default-consumes-media-type: application/json
default-produces-media-type: application/json
alibaba:
cloud:
sms:
access-key-id: ${ALIBABA_ACCESS_KEY_ID:}
access-key-secret: ${ALIBABA_ACCESS_KEY_SECRET:}
sign-name: ${ALIBABA_SMS_SIGN_NAME:}
template-code: ${ALIBABA_SMS_TEMPLATE_CODE:}
code-length: 6
code-expire-seconds: 300
@@ -0,0 +1,2 @@
huifu.private-key=MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCNUdQE59I4bV1dIgLs2IRN3Wl8KI2yS4KpTUQWrvvlUcBTztB8rIeN5lr6Yv4VsZ1TG0FAb0D80JjxEQ1+0HizYNOQ1h2935v2r8h7rx3VTLbMOkmsg8Lb8LQRTbPaJOZfsIZMzBytYiRzWunHaMlCr800EA3q5NMj9VjHQuamxKqyzyHqfkIjA/sZ8q9atVjn8ahUjPKdrGA8b79HexHhCgOSLdK+fWw0eMCsWWYP2qECLsvZ+tjfvqSBXx1kg7womwT1VBCf+0Dx+jJPKR3mxQfz2szoucJYuXRo55kA6yCwoeNsjanLDRkPBSy3NHdKrffP6YODhRHG6KHyayyNAgMBAAECggEATIR69TEETVNCEzRgOxe9A2AYRoa6ukhSdhMFA/c5IuCR747yqh7MwtNwfVRuWRazpZUDTr0uhfT4asad9QUx5YZO54RX1EAn9XkWZ4nY8G46J/iDfapWLrp09U2KTVpfdn5hKWH3QRX7wI4AON2O49HGnSL4NjAx9q1YpYOe2bqevxmB4uD9vR/FHMjZ2qWzPaL7pId98x4DCJCiZctBQqb7gxR8EseD1Ddn2HbH5DEUjoxz2umtL+zrcGKDFBy4kM3r+10FP5jFG4fJfZFQwrzMTEkXJriau9DMRTQ83rWHzNvhdTNUuyqah74ez5V1piVEOXCPudzFsMttD2DyzQKBgQDokQH3WtcQqwFTUo+uWVVup4NoQzNZpEGIHK17afRsf9YnkoJVqjhywq1dhQT+yuyW9geJJKWpHQIAS4gGudhNK+j6d//bbvvbkOmtsdWyZ6Z2EmY8JrPkHdHAcadNJX7vrIc1xPeyci4AHhErDHc3hhD5njuSLx3DUp4iODNxKwKBgQCbjyHvP90YGjh49DWklh2NJgR2+OR6Fd0afdmwtGKeCUP5l2To6rfLBbcSRIWCON4iLME5wLLdHcm5wAQukC4dsUB5iZffp21+7hrrwUw/G0MUzFudfhZkjsDU5i+FwnVMqmsLJPUsjJTOfKsJdA2DjnwfMcgxw9FeClGveSFNJwKBgQDCpjuDECDY7oeZeYyQXGzIxKOTbEtaR8Qha/83QCM3fHd9f35evK2qP45iq6bWqnkCkMEV4/pTZNf77zvWhU2oqYvBtxYKTwW1a8BphGJbg60rPZMb3TjLQLoB3B4uz6dCaqBwPH8kd7RQnNm5siFF84vZoLozTAQZKtj3wxorKQKBgQCGKnEONHqwaw0B5T7O8VoTfxKiug/07B6C1sCGk03rF/q0rkquSKK0S/2Vl9u+cOXFe+w7r2OVKjfuKRpyPpBHs7T0HiQLFhBuRVaat2DXnN/CdG8f6rvNhwHxnYanSwx4TxN7zShYf/doEEZEJP/y01ViYkFUCpvtC+FgAo0iSQKBgFrkJGxPo6T7i5ZF7VoZWdARnypfbOl4gF9GMrXykk0fnKBDTof9Bg34a8OX54cf4yEH1ifZzVtNyzPTH9FxV88TksWRouqi1Uo6///kJI6gXROspE91TpNtNGHPgLhebA83WlFtS/H7i5G6RO+6KO0uflSImfy3Owp/ChyFUGmn
huifu.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjVHUBOfSOG1dXSIC7NiETd1pfCiNskuCqU1EFq775VHAU87QfKyHjeZa+mL+FbGdUxtBQG9A/NCY8RENftB4s2DTkNYdvd+b9q/Ie68d1Uy2zDpJrIPC2/C0EU2z2iTmX7CGTMwcrWIkc1rpx2jJQq/NNBAN6uTTI/VYx0LmpsSqss8h6n5CIwP7GfKvWrVY5/GoVIzynaxgPG+/R3sR4QoDki3Svn1sNHjArFlmD9qhAi7L2frY376kgV8dZIO8KJsE9VQQn/tA8foyTykd5sUH89rM6LnCWLl0aOeZAOsgsKHjbI2pyw0ZDwUstzR3Sq33z+mDg4URxuih8mssjQIDAQAB
@@ -16,9 +16,22 @@ wechat:
# 手机号加密配置
phone-encryption:
secret-key: ${PHONE_ENCRYPTION_SECRET_KEY}
iv: ${PHONE_ENCRYPTION_IV}
secret-key: ${PHONE_ENCRYPTION_SECRET_KEY:P8539ANjWJWsRbVHZKhM8Q==}
iv: ${PHONE_ENCRYPTION_IV:3tHp07uMRYh1xKsIXvYJMA==}
spring:
elasticsearch:
uris: http://localhost:9200
uris: http://localhost:9200
payment:
huifu:
sys-id: "6666000207573586"
product-id: "XLSISV"
huifu-id: "6666000207573586"
acct-id: "F28226571"
private-key: ${huifu.private-key}
public-key: ${huifu.public-key}
create-url: "https://api.huifu.com/v4/trade/payment/create"
query-url: "https://api.huifu.com/v4/trade/payment/query"
refund-url: "https://api.huifu.com/v4/trade/refund"
notify-url: "http://localhost:8084/api/payment/notify"
@@ -0,0 +1,19 @@
package cn.novalon.gym.manage.common.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "auth")
public class AuthConfig {
private String accessKeyId;
private String accessKeySecret;
private Integer tokenExpireSeconds = 86400;
private Integer refreshTokenExpireSeconds = 604800;
}
@@ -61,4 +61,13 @@ public final class RedisKeyConstants {
* appType: miniapp(小程序), mp(公众号)
*/
public static final String WECHAT_ACCESS_TOKEN = "wechat:access_token:";
// ==================== 认证模块 ====================
/**
* 手机短信验证码缓存
* 格式:sms:code:{phone}
* 用途:存储登录/注册等场景的短信验证码
*/
public static final String SMS_CODE = "sms:code:";
}
@@ -7,6 +7,7 @@ public class ErrorCode {
public static final String PERMISSION_PREFIX = "PERMISSION_";
public static final String CONFLICT_PREFIX = "CONFLICT_";
public static final String SYSTEM_PREFIX = "SYSTEM_";
public static final String AUTH_PREFIX = "AUTH_";
public static final String VALIDATION_REQUIRED = VALIDATION_PREFIX + "001";
public static final String VALIDATION_INVALID_FORMAT = VALIDATION_PREFIX + "002";
@@ -29,4 +30,7 @@ public class ErrorCode {
public static final String SYSTEM_INTERNAL_ERROR = SYSTEM_PREFIX + "001";
public static final String SYSTEM_DATABASE_ERROR = SYSTEM_PREFIX + "002";
public static final String SYSTEM_NETWORK_ERROR = SYSTEM_PREFIX + "003";
public static final String AUTH_PHONE_ERROR = AUTH_PREFIX + "001";
public static final String AUTH_CODE_ERROR = AUTH_PREFIX + "002";
}
@@ -0,0 +1,54 @@
package cn.novalon.gym.manage.common.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
public static <T> Result<T> success(T data) {
return Result.<T>builder()
.code(200)
.message("success")
.data(data)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> Result<T> success(String message) {
return Result.<T>builder()
.code(200)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> Result<T> fail(Integer code, String message) {
return Result.<T>builder()
.code(code)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> Result<T> fail(String message) {
return Result.<T>builder()
.code(500)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
}
@@ -0,0 +1,86 @@
package cn.novalon.gym.manage.common.util;
import cn.novalon.gym.manage.common.config.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final JwtProperties jwtProperties;
private SecretKey getSigningKey() {
String secret = jwtProperties.getSecret();
if (secret.length() < 32) {
StringBuilder sb = new StringBuilder(secret);
while (sb.length() < 32) {
sb.append(secret);
}
secret = sb.substring(0, 32);
}
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String userId, String phone) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtProperties.getExpiration());
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("phone", phone);
return Jwts.builder()
.setClaims(claims)
.setSubject(userId)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
public String generateRefreshToken(String userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtProperties.getExpiration() * 7);
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
public String getUserIdFromToken(String token) {
return parseToken(token).getSubject();
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
log.warn("JWT token validation failed: {}", e.getMessage());
return false;
}
}
}
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.db.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@@ -21,10 +22,10 @@ public class SysUserMessageEntity {
@Column("user_id")
private Long userId;
@Column("title")
@Column("message_title")
private String title;
@Column("content")
@Column("message_content")
private String content;
@Column("message_type")
@@ -33,9 +34,13 @@ public class SysUserMessageEntity {
@Column("is_read")
private String isRead;
@Column("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Column("created_at")
private LocalDateTime createTime;
@Column("deleted_at")
private LocalDateTime deletedAt;
public Long getId() {
return id;
}
@@ -91,4 +96,12 @@ public class SysUserMessageEntity {
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.db.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
import cn.novalon.gym.manage.notify.core.repository.ISysUserMessageRepository;
import cn.novalon.gym.manage.db.converter.SysUserMessageConverter;
@@ -13,6 +15,8 @@ import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
/**
* 用户消息仓储实现类
*
@@ -75,6 +79,87 @@ public class SysUserMessageRepository implements ISysUserMessageRepository {
return r2dbcEntityTemplate.count(dbQuery, SysUserMessageEntity.class);
}
@Override
public Mono<PageResponse<SysUserMessage>> findByUserIdPage(Long userId, PageRequest pageRequest) {
int page = pageRequest.getPage();
int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
Sort sortObj = Sort.unsorted();
if (sort != null && !sort.isEmpty()) {
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
}
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
SysUserMessageQueryCriteria criteria = new SysUserMessageQueryCriteria();
criteria.setUserId(userId);
org.springframework.data.relational.core.query.Query dbQuery = QueryUtil.getQuery(criteria);
return r2dbcEntityTemplate.select(SysUserMessageEntity.class)
.matching(dbQuery.with(pageable))
.all()
.collectList()
.zipWith(r2dbcEntityTemplate.count(dbQuery, SysUserMessageEntity.class))
.map(tuple -> {
long total = tuple.getT2();
int totalPages = (int) Math.ceil((double) total / size);
List<SysUserMessage> messageList = tuple.getT1().stream()
.map(sysUserMessageConverter::toDomain)
.toList();
return new PageResponse<>(messageList, totalPages, total, page, size);
});
}
@Override
public Mono<PageResponse<SysUserMessage>> findByUserIdAndIsReadPage(Long userId, String isRead, PageRequest pageRequest) {
int page = pageRequest.getPage();
int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
Sort sortObj = Sort.unsorted();
if (sort != null && !sort.isEmpty()) {
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
}
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
SysUserMessageQueryCriteria criteria = new SysUserMessageQueryCriteria();
criteria.setUserId(userId);
criteria.setIsRead(isRead);
org.springframework.data.relational.core.query.Query dbQuery = QueryUtil.getQuery(criteria);
return r2dbcEntityTemplate.select(SysUserMessageEntity.class)
.matching(dbQuery.with(pageable))
.all()
.collectList()
.zipWith(r2dbcEntityTemplate.count(dbQuery, SysUserMessageEntity.class))
.map(tuple -> {
long total = tuple.getT2();
int totalPages = (int) Math.ceil((double) total / size);
List<SysUserMessage> messageList = tuple.getT1().stream()
.map(sysUserMessageConverter::toDomain)
.toList();
return new PageResponse<>(messageList, totalPages, total, page, size);
});
}
@Override
public Mono<Long> markAllAsReadByUserId(Long userId) {
org.springframework.data.relational.core.query.Update update = org.springframework.data.relational.core.query.Update.update("is_read", "1");
org.springframework.data.relational.core.query.Query query = org.springframework.data.relational.core.query.Query.query(
org.springframework.data.relational.core.query.Criteria.where("user_id").is(userId)
.and("deleted_at").isNull()
);
return r2dbcEntityTemplate.update(SysUserMessageEntity.class)
.matching(query)
.apply(update);
}
@Override
public Mono<SysUserMessage> save(SysUserMessage message) {
SysUserMessageEntity entity = sysUserMessageConverter.toEntity(message);
@@ -0,0 +1,99 @@
-- ============================================
-- 签到记录表(sign_in_record
-- ============================================
-- Step 1: 创建 sign_in_record 表
CREATE TABLE IF NOT EXISTS sign_in_record (
-- ========== 主键和基础字段 ==========
id BIGSERIAL PRIMARY KEY, -- 自增主键
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 记录创建时间
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 记录更新时间
-- ========== 会员相关字段 ==========
member_id BIGINT NOT NULL, -- 会员ID,关联member_user表
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, -- JSONB格式,存储会员卡验证时的快照数据
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-已删除
);
-- Step 2: 创建索引
-- 会员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);
-- 复合索引:签到日期(用于每日统计,使用表达式索引)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_date ON sign_in_record(DATE(sign_in_time));
-- Step 3: 添加外键约束(可选,根据业务需求决定)
-- 注意:如果member_user表使用了软删除,外键约束可能需要谨慎使用
-- ALTER TABLE sign_in_record
-- ADD CONSTRAINT fk_sign_in_record_member
-- FOREIGN KEY (member_id) REFERENCES member_user(id) ON DELETE SET NULL;
-- Step 4: 添加注释
COMMENT ON TABLE sign_in_record IS '会员到店签到记录表';
COMMENT ON COLUMN sign_in_record.id IS '自增主键';
COMMENT ON COLUMN sign_in_record.created_at IS '记录创建时间';
COMMENT ON COLUMN sign_in_record.updated_at IS '记录更新时间';
COMMENT ON COLUMN sign_in_record.member_id IS '会员ID,关联member_user表';
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-已删除';
@@ -0,0 +1,38 @@
-- ============================================
-- 团课推荐表
-- ============================================
-- 团课推荐表
CREATE TABLE IF NOT EXISTS group_course_recommend (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL,
recommend_title VARCHAR(200),
recommend_content TEXT,
recommend_reason VARCHAR(500),
priority INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_course_id ON group_course_recommend(course_id);
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_priority ON group_course_recommend(priority);
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_is_active ON group_course_recommend(is_active);
COMMENT ON TABLE group_course_recommend IS '团课推荐表';
COMMENT ON COLUMN group_course_recommend.id IS '主键ID';
COMMENT ON COLUMN group_course_recommend.course_id IS '团课ID(关联group_course.id';
COMMENT ON COLUMN group_course_recommend.recommend_title IS '推荐标题';
COMMENT ON COLUMN group_course_recommend.recommend_content IS '推荐内容';
COMMENT ON COLUMN group_course_recommend.recommend_reason IS '推荐理由';
COMMENT ON COLUMN group_course_recommend.priority IS '优先级(数字越大优先级越高)';
COMMENT ON COLUMN group_course_recommend.is_active IS '是否启用';
COMMENT ON COLUMN group_course_recommend.create_by IS '创建人';
COMMENT ON COLUMN group_course_recommend.update_by IS '更新人';
COMMENT ON COLUMN group_course_recommend.created_at IS '创建时间';
COMMENT ON COLUMN group_course_recommend.updated_at IS '更新时间';
COMMENT ON COLUMN group_course_recommend.deleted_at IS '删除时间(软删除)';
@@ -0,0 +1,43 @@
-- ============================================
-- 团课推荐测试数据
-- ============================================
-- 推荐数据1: 极速燃脂单车 - 高优先级推荐(热门课程)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(1, '本周热门推荐', '极速燃脂单车课程,跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡!', '教练专业,课程内容丰富,深受学员喜爱,燃脂效果显著', 20, true, 'admin', '2026-06-15 10:00:00', '2026-06-15 10:00:00');
-- 推荐数据2: 清晨流瑜伽 - 中等优先级推荐(适合新手)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(2, '新手友好推荐', '清晨流瑜伽课程,适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。', '适合新手入门,教练耐心指导,课程节奏适中', 15, true, 'admin', '2026-06-15 11:00:00', '2026-06-15 11:00:00');
-- 推荐数据3: 燃脂搏击 - 低优先级推荐(满员课程,已禁用)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(3, '高强度燃脂', '燃脂搏击课程,高强度间歇训练,配合音乐快速燃脂,释放压力。', '高强度训练,适合进阶学员,快速燃脂塑形', 10, false, 'admin', '2026-06-15 12:00:00', '2026-06-15 12:00:00');
-- 推荐数据4: 哈他瑜伽 - 中等优先级推荐(基础课程)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(4, '基础瑜伽推荐', '基础哈他瑜伽课程,适合所有级别学员,通过基础体式练习提升身体柔韧性和平衡能力。', '零基础友好,适合所有健身水平,放松身心', 12, true, 'coach_li', '2026-06-15 13:00:00', '2026-06-15 13:00:00');
-- 推荐数据5: 蜜桃臀塑造 - 高优先级推荐(热门课程,但课程已结束)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(6, '塑形热门课程', '蜜桃臀塑造课程,针对性训练臀部肌肉群,打造完美曲线。', '专业私教指导,动作标准,效果显著,深受女性学员喜爱', 18, true, 'coach_li', '2026-05-25 09:15:00', '2026-05-25 09:15:00');
-- 推荐数据6: 午间冥想放松 - 低优先级推荐(放松课程)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(7, '午间放松推荐', '午间冥想放松课程,通过呼吸和正念冥想,深度放松身心,缓解工作压力。', '适合上班族,午间放松充电,提升下午工作效率', 8, true, 'admin', '2026-05-25 09:00:00', '2026-05-25 09:00:00');
-- 推荐数据7: 极速燃脂单车 - 第二个推荐(不同角度推荐)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(1, '减脂首选课程', '想要快速减脂?极速燃脂单车是你的最佳选择!专业教练带领,科学训练计划。', '减脂效果最佳,课程强度适中,适合想要快速瘦身的学员', 16, true, 'coach_zhang', '2026-06-15 14:00:00', '2026-06-15 14:00:00');
-- 推荐数据8: 清晨流瑜伽 - 第二个推荐(不同角度推荐)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(2, '晨练优选', '清晨流瑜伽,唤醒身体能量,开启活力一天!适合晨练爱好者。', '晨练最佳选择,提升身体活力,改善精神状态', 14, true, 'coach_wang', '2026-06-15 15:00:00', '2026-06-15 15:00:00');
-- 推荐数据9: 哈他瑜伽 - 第二个推荐(不同角度推荐)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(4, '身心平衡推荐', '哈他瑜伽课程,通过体式练习和呼吸控制,达到身心平衡,提升整体健康水平。', '改善身体柔韧性,增强核心力量,提升身体协调性', 11, true, 'coach_li', '2026-06-15 16:00:00', '2026-06-15 16:00:00');
-- 推荐数据10: 午间冥想放松 - 第二个推荐(不同角度推荐,已禁用)
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
(7, '职场减压课程', '午间冥想放松,专为职场人士设计,快速缓解工作压力,提升工作状态。', '职场减压首选,课程时间短,效果显著', 9, false, 'admin', '2026-05-25 10:00:00', '2026-05-25 10:00:00');
@@ -0,0 +1,9 @@
-- ============================================
-- 为团课表添加二维码路径字段
-- ============================================
-- 添加二维码路径字段
ALTER TABLE group_course ADD COLUMN qr_code_path VARCHAR(500);
-- 添加字段注释
COMMENT ON COLUMN group_course.qr_code_path IS '二维码图片路径';
@@ -0,0 +1,21 @@
-- ============================================
-- V20: 更新 member_user 表 - 添加阿里云号码认证字段
-- 支持一键登录功能
-- ============================================
-- 添加阿里云号码认证相关字段
ALTER TABLE IF EXISTS member_user
ADD COLUMN IF NOT EXISTS dypns_open_id VARCHAR(100),
ADD COLUMN IF NOT EXISTS id_card VARCHAR(50),
ADD COLUMN IF NOT EXISTS real_name VARCHAR(50),
ADD COLUMN IF NOT EXISTS register_channel VARCHAR(50) DEFAULT 'SMS';
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_member_user_dypns_open_id ON member_user(dypns_open_id);
CREATE INDEX IF NOT EXISTS idx_member_user_register_channel ON member_user(register_channel);
-- 添加字段注释
COMMENT ON COLUMN member_user.dypns_open_id IS '阿里云号码认证OpenID(一键登录用户唯一标识)';
COMMENT ON COLUMN member_user.id_card IS '身份证号码(AES加密存储)';
COMMENT ON COLUMN member_user.real_name IS '真实姓名(AES加密存储)';
COMMENT ON COLUMN member_user.register_channel IS '注册渠道:SMS-短信验证码,ONE_CLICK-一键登录,WECHAT-微信授权';
@@ -61,7 +61,9 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
path.equals("/api/member/auth/miniapp/login") ||
path.equals("/api/member/auth/mp/callback") ||
path.equals("/api/auth/login") ||
path.startsWith("/api/checkIn/") ||
path.equals("/api/groupCourse/page") ||
path.startsWith("/api/checkIn") ||
path.startsWith("/api/payment") ||
path.startsWith("/actuator/info");
}
@@ -64,7 +64,7 @@ signature:
max-age-minutes: ${SIGNATURE_MAX_AGE_MINUTES:5}
nonce-cache-size: ${SIGNATURE_NONCE_CACHE_SIZE:10000}
whitelist:
paths: ${SIGNATURE_WHITELIST_PATHS:/actuator/health,/actuator/info,/api/auth/login,/api/auth/register,/api/member/auth/miniapp/login,/api/member/auth/mp/callback}
paths: ${SIGNATURE_WHITELIST_PATHS:/actuator/health,/actuator/info,/api/auth/login,/api/auth/register,/api/member/auth/miniapp/login,/api/member/auth/mp/callback,/api/groupCourse/**}
resilience:
enabled: ${RESILIENCE_ENABLED:true}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.notify.core.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
public class SysUserMessage {
@@ -10,6 +12,8 @@ public class SysUserMessage {
private String content;
private String messageType;
private String isRead;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
public Long getId() { return id; }
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.notify.core.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -12,6 +14,12 @@ public interface ISysUserMessageRepository {
Mono<Long> countByUserIdAndIsRead(Long userId, String isRead);
Mono<PageResponse<SysUserMessage>> findByUserIdPage(Long userId, PageRequest pageRequest);
Mono<PageResponse<SysUserMessage>> findByUserIdAndIsReadPage(Long userId, String isRead, PageRequest pageRequest);
Mono<Long> markAllAsReadByUserId(Long userId);
Mono<SysUserMessage> save(SysUserMessage message);
Mono<SysUserMessage> findById(Long id);
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.notify.core.service;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -12,6 +14,12 @@ public interface ISysUserMessageService {
Flux<SysUserMessage> getUnreadMessages(Long userId);
Mono<PageResponse<SysUserMessage>> getMessagesByUserPage(Long userId, PageRequest pageRequest);
Mono<PageResponse<SysUserMessage>> getUnreadMessagesPage(Long userId, PageRequest pageRequest);
Mono<Long> markAllAsRead(Long userId);
Mono<SysUserMessage> createMessage(SysUserMessage message);
Mono<SysUserMessage> markAsRead(Long id);
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.notify.core.service.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
import cn.novalon.gym.manage.notify.core.repository.ISysUserMessageRepository;
import cn.novalon.gym.manage.notify.core.service.ISysUserMessageService;
@@ -33,6 +35,16 @@ public class SysUserMessageServiceImpl implements ISysUserMessageService {
return messageRepository.findByUserIdAndIsReadOrderByCreateTimeDesc(userId, "0");
}
@Override
public Mono<PageResponse<SysUserMessage>> getMessagesByUserPage(Long userId, PageRequest pageRequest) {
return messageRepository.findByUserIdPage(userId, pageRequest);
}
@Override
public Mono<PageResponse<SysUserMessage>> getUnreadMessagesPage(Long userId, PageRequest pageRequest) {
return messageRepository.findByUserIdAndIsReadPage(userId, "0", pageRequest);
}
@Override
public Mono<SysUserMessage> createMessage(SysUserMessage message) {
message.setCreateTime(LocalDateTime.now());
@@ -40,6 +52,11 @@ public class SysUserMessageServiceImpl implements ISysUserMessageService {
return messageRepository.save(message);
}
@Override
public Mono<Long> markAllAsRead(Long userId) {
return messageRepository.markAllAsReadByUserId(userId);
}
@Override
public Mono<SysUserMessage> markAsRead(Long id) {
return messageRepository.findById(id)
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.notify.handler;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
import cn.novalon.gym.manage.notify.core.service.ISysUserMessageService;
import org.springframework.stereotype.Component;
@@ -23,6 +24,24 @@ public class SysUserMessageHandler {
return ServerResponse.ok().body(messages, SysUserMessage.class);
}
public Mono<ServerResponse> getMessagesByUserPage(ServerRequest request) {
Long userId = Long.parseLong(request.pathVariable("userId"));
int page = Integer.parseInt(request.queryParam("page").orElse("0"));
int size = Integer.parseInt(request.queryParam("size").orElse("10"));
String sort = request.queryParam("sort").orElse("createTime");
String order = request.queryParam("order").orElse("desc");
PageRequest pageRequest = new PageRequest();
pageRequest.setPage(page);
pageRequest.setSize(size);
pageRequest.setSort(sort);
pageRequest.setOrder(order);
return messageService.getMessagesByUserPage(userId, pageRequest)
.flatMap(pageResponse -> ServerResponse.ok().bodyValue(pageResponse));
}
public Mono<ServerResponse> getUnreadCount(ServerRequest request) {
Long userId = Long.parseLong(request.pathVariable("userId"));
return messageService.getUnreadCount(userId)
@@ -35,6 +54,24 @@ public class SysUserMessageHandler {
return ServerResponse.ok().body(messages, SysUserMessage.class);
}
public Mono<ServerResponse> getUnreadMessagesPage(ServerRequest request) {
Long userId = Long.parseLong(request.pathVariable("userId"));
int page = Integer.parseInt(request.queryParam("page").orElse("0"));
int size = Integer.parseInt(request.queryParam("size").orElse("10"));
String sort = request.queryParam("sort").orElse("createTime");
String order = request.queryParam("order").orElse("desc");
PageRequest pageRequest = new PageRequest();
pageRequest.setPage(page);
pageRequest.setSize(size);
pageRequest.setSort(sort);
pageRequest.setOrder(order);
return messageService.getUnreadMessagesPage(userId, pageRequest)
.flatMap(pageResponse -> ServerResponse.ok().bodyValue(pageResponse));
}
public Mono<ServerResponse> createMessage(ServerRequest request) {
return request.bodyToMono(SysUserMessage.class)
.flatMap(messageService::createMessage)
@@ -48,6 +85,12 @@ public class SysUserMessageHandler {
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono<ServerResponse> markAllAsRead(ServerRequest request) {
Long userId = Long.parseLong(request.pathVariable("userId"));
return messageService.markAllAsRead(userId)
.flatMap(count -> ServerResponse.ok().bodyValue(count));
}
public Mono<ServerResponse> deleteMessage(ServerRequest request) {
Long id = Long.parseLong(request.pathVariable("id"));
return messageService.deleteMessage(id)
@@ -21,7 +21,7 @@ public class SecurityConfig {
private final OperationLogWebFilter operationLogWebFilter;
private final Environment environment;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
OperationLogWebFilter operationLogWebFilter,
Environment environment) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
@@ -33,11 +33,11 @@ public class SecurityConfig {
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
String[] activeProfiles = environment.getActiveProfiles();
final boolean isDevOrTest;
isDevOrTest = java.util.Arrays.stream(activeProfiles)
.anyMatch(profile -> "dev".equals(profile) || "test".equals(profile) || "h2-test".equals(profile));
logger.info("SecurityConfig初始化: 当前环境={}, Swagger启用状态={}",
logger.info("SecurityConfig初始化: 当前环境={}, Swagger启用状态={}",
activeProfiles.length > 0 ? String.join(",", activeProfiles) : "default", isDevOrTest);
http
@@ -58,8 +58,13 @@ public class SecurityConfig {
.pathMatchers("/api/member-card-records/**").permitAll()
.pathMatchers("/**").permitAll()
.pathMatchers("/api/member-card-transactions/**").permitAll()
.pathMatchers("/api/checkIn/**").permitAll();
.pathMatchers("/api/groupCourse/page").permitAll()
.pathMatchers("/api/checkIn/**").permitAll()
.pathMatchers("/api/payment/**").permitAll()
.pathMatchers("/api/payment/create").permitAll()
.pathMatchers("/api/payment/query").permitAll()
.pathMatchers("/api/payment/notify").permitAll()
.pathMatchers("/api/payment/refund").permitAll();
if (isDevOrTest) {
spec.pathMatchers("/swagger-ui.html").permitAll()
@@ -0,0 +1,46 @@
package cn.novalon.gym.manage.sys;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import cn.novalon.gym.manage.common.config.JwtProperties;
import org.junit.jupiter.api.Test;
public class TokenGenerator {
@Test
public void generateTokenForUser1_Dev() {
// dev环境secret
JwtProperties jwtProperties = new JwtProperties();
jwtProperties.setSecret("novalon-gym-manage-jwt-secret-key-for-development-only-2026");
JwtTokenProvider provider = new JwtTokenProvider(jwtProperties);
String token = provider.generateToken("admin", 1L);
System.out.println("【Dev环境】Token for userId=1:");
System.out.println(token);
System.out.println();
}
@Test
public void generateTokenForUser1_Local() {
// local环境secret
JwtProperties jwtProperties = new JwtProperties();
jwtProperties.setSecret("U2FsdGVkX1+vZ5Y9QmKxL8nN3rP7tW2jH4fG6dA8sB1cE5yN0zX3qV7wM4");
JwtTokenProvider provider = new JwtTokenProvider(jwtProperties);
String token = provider.generateToken("admin", 1L);
System.out.println("【Local环境】Token for userId=1:");
System.out.println(token);
System.out.println();
}
@Test
public void generateTokenForUser1_Default() {
// 默认环境secret
JwtProperties jwtProperties = new JwtProperties();
jwtProperties.setSecret("U2FsdGVkX1+vZ5Y9QmKxL8nN3rP7tW2jH4fG6dA8sB1cE5yN0zX3qV7wM4");
JwtTokenProvider provider = new JwtTokenProvider(jwtProperties);
String token = provider.generateToken("admin", 1L);
System.out.println("【默认环境】Token for userId=1:");
System.out.println(token);
}
}
+1
View File
@@ -46,6 +46,7 @@
<module>gym-groupCourse</module>
<module>gym-checkIn</module>
<module>gym-dataCount</module>
<module>gym-auth</module>
</modules>
<dependencyManagement>
-4
View File
@@ -1,4 +0,0 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
-33
View File
@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="gym.manage.uniapp">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<application>
<activity
android:name="com.mobile.auth.gatewayauth.LoginAuthActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind"
android:theme="@style/authsdk_activity_dialog" />
<activity
android:name="com.mobile.auth.gatewayauth.activity.AuthWebVeiwActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind" />
<activity
android:name="com.mobile.auth.gatewayauth.PrivacyDialogActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind"
android:theme="@style/authsdk_activity_dialog" />
</application>
</manifest>
-85
View File
@@ -1,85 +0,0 @@
<template>
<view>
<GlobalLoading />
</view>
</template>
<script setup>
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import GlobalLoading from '@/components/global/GlobalLoading.vue'
// 隐藏原生 TabBar - 这里是核心修复
const hideNativeTabBar = () => {
// 尝试多次执行,确保执行成功
const tryHide = (times = 0) => {
if (times > 10) return // 最多尝试10次
uni.hideTabBar({
animation: false,
success: () => {
console.log('✅ 原生TabBar隐藏成功')
// 强制 CSS 覆盖(双重保险)
forceCSSHide()
},
fail: (err) => {
console.log(`❌ 第${times+1}次隐藏失败,1秒后重试`, err)
setTimeout(() => tryHide(times + 1), 1000)
}
})
}
// 延迟 300ms 执行,确保页面挂载完成
setTimeout(() => tryHide(), 300)
}
// 强制 CSS 覆盖(最终保险)
const forceCSSHide = () => {
// #ifdef APP-PLUS
const style = document.createElement('style')
style.innerHTML = `
uni-tabbar,
uni-tabbar .uni-tabbar,
.uni-tabbar,
uni-tabbar > .uni-tabbar,
[class*="uni-tabbar"] {
display: none !important;
height: 0 !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
`
document.head.appendChild(style)
console.log('✅ CSS 强制覆盖已注入')
// #endif
}
// 预加载所有 Tab 页面的核心数据
const preloadTabData = () => {
// 延迟执行,不阻塞首屏
setTimeout(() => {
// 预加载课程数据等...
}, 1000)
}
onLaunch(() => {
console.log('App Launch')
preloadTabData()
})
onShow(() => {
console.log('App Show')
// #ifdef APP-PLUS
hideNativeTabBar()
// #endif
})
onHide(() => {
console.log('App Hide')
})
</script>
<style lang="scss">
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
@import "@/uni.scss";
</style>
-132
View File
@@ -1,132 +0,0 @@
import request from "@/utils/request.js"
export function getGroupCourseList(params = {}, options = {}) {
return request.get('/groupCourse/list', params, options)
}
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
}
export function getGroupCourseById(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/groupCourse/${id}`, {}, options)
}
export function getGroupCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/groupCourse/${id}/detail`, {}, options)
}
export function createGroupCourse(params) {
return request.post('/groupCourse', params)
}
export function updateGroupCourse(id, params) {
return request.put(`/groupCourse/${id}`, params)
}
export function cancelGroupCourse(id) {
return request.post(`/groupCourse/${id}/cancel`)
}
export function deleteGroupCourse(id) {
return request.delete(`/groupCourse/${id}`)
}
export function getGroupCourseTypes(params = {}, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/groupCourse/types', params, options)
}
export function getGroupCourseTypeById(id, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get(`/groupCourse/types/${id}`, {}, options)
}
export function getTypeLabels(typeId, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
return request.get(`/groupCourse/types/${typeId}/labels`, {}, options)
}
export function searchGroupCourse(params = {}, options = {}) {
const {
courseName,
courseType,
startDate,
endDate,
timePeriod,
priceSort,
remainingMost,
isRecurring,
page = 0,
size = 10
} = params
const requestBody = { page, size }
if (courseName) requestBody.courseName = courseName
if (courseType) requestBody.courseType = courseType
if (startDate) requestBody.startDate = formatDateTime(startDate)
if (endDate) requestBody.endDate = formatDateTime(endDate, true)
if (timePeriod) requestBody.timePeriod = timePeriod
if (priceSort) requestBody.priceSort = priceSort
if (remainingMost !== undefined) requestBody.remainingMost = remainingMost
if (isRecurring !== undefined) requestBody.isRecurring = isRecurring
return request.post('/groupCourse/search', requestBody, options)
}
function formatDateTime(dateStr, isEnd = false) {
if (!dateStr) return dateStr
if (dateStr.includes('T')) return dateStr
return isEnd
? `${dateStr}T23:59:59`
: `${dateStr}T00:00:00`
}
export function bookGroupCourse(params) {
return request.post('/groupCourse/book', params)
}
export function cancelBooking(bookingId, params) {
return request.post(`/groupCourse/booking/${bookingId}/cancel`, params)
}
export function getMemberBookings(memberId, options = {}) {
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
}
export function getActiveRecommendCourses(options = { cache: false }) {
return request.get('/groupCourse/recommend/active', {}, options)
}
export function getGroupCourseRecommendList(params = {}, options = { cache: false }) {
return request.get('/groupCourse/recommend/list', params, options)
}
/**
* 扫码签到
* 扫描团课二维码后签到,将预约状态更新为已出席
* @param {number} courseId - 团课ID
* @param {number} memberId - 会员ID
*/
export function qrSignInGroupCourse(courseId, memberId) {
return request.post(`/groupCourse/signin/${memberId}`, { courseId })
}
export default {
getGroupCourseList,
getGroupCoursePage,
searchGroupCourse,
getGroupCourseById,
getGroupCourseDetail,
createGroupCourse,
updateGroupCourse,
cancelGroupCourse,
deleteGroupCourse,
getGroupCourseTypes,
getGroupCourseTypeById,
getTypeLabels,
bookGroupCourse,
cancelBooking,
getMemberBookings,
getActiveRecommendCourses,
getGroupCourseRecommendList
}
-294
View File
@@ -1,294 +0,0 @@
import request from "@/utils/request.js"
/**
* 微信小程序登录
* @param {object} params - 登录参数 { code: string }
*/
export function login(params) {
return request.post('/member/auth/miniapp/login', params)
}
/**
* 发送短信验证码
* @param {object} params - { phone: string }
*/
export function sendCode(params) {
return request.post('/auth/phone/send-code', params)
}
/**
* 手机号验证码登录
* @param {object} params - { phone: string, code: string }
*/
export function loginWithPhone(params) {
return request.post('/auth/phone/code-login', params)
}
/**
* 手机号一键登录
* @param {object} params - { accessToken: string, openid: string, nickname?: string, avatar?: string }
*/
export function oneClickLogin(params) {
return request.post('/auth/phone/one-click-login', params)
}
/**
* 退出登录
*/
export function logout() {
return request.post('/member/auth/logout')
}
/**
* 获取签到二维码
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getQRCode(options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
return request.get('/checkIn/qrcode', {}, options)
}
/**
* 扫码签到
* @param {object} params - { qrcode: string, memberId: number }
*/
export function checkIn(params) {
return request.post('/checkIn', params)
}
/**
* 获取签到记录(分页)
* @param {object} params - { page: number, size: number }
* @param {object} options - 请求选项
*/
export function getCheckInRecords(params = {}, options = {}) {
const { page = 0, size = 5 } = params
return request.post('/checkIn/page', { page, size }, options)
}
/**
* 获取用户未读消息数量
* @param {number} userId - 用户ID
* @param {object} options - 请求选项
*/
export function getUnreadMessageCount(userId, options = {}) {
return request.get(`/messages/user/${userId}/unread`, {}, options)
}
// ========== 消息相关API ==========
/**
* 获取用户消息列表(支持分页)
* @param {number} userId - 用户ID
* @param {object} params - 查询参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.size - 每页条数
* @param {object} options - 请求选项
* @returns {Promise} 消息列表
*/
export function getUserMessages(userId, params = {}, options = {}) {
const { page = 0, size = 10 } = params
return request.get(`/messages/user/${userId}/page`, { page, size }, options)
}
/**
* 获取未读消息列表(支持分页)
* @param {number} userId - 用户ID
* @param {object} params - 查询参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.size - 每页条数
* @param {object} options - 请求选项
* @returns {Promise} 未读消息列表
*/
export function getUnreadMessages(userId, params = {}, options = {}) {
const { page = 0, size = 10 } = params
return request.get(`/messages/user/${userId}/unread/page`, { page, size }, options)
}
/**
* 标记消息为已读
* @param {number|string} id - 消息ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function markMessageAsRead(id, options = {}) {
return request.put(`/messages/${id}/read`, {}, options)
}
/**
* 标记所有消息为已读
* @param {number} userId - 用户ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function markAllMessagesAsRead(userId, options = {}) {
return request.put(`/messages/user/${userId}/read`, {}, options)
}
/**
* 删除消息
* @param {number|string} id - 消息ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function deleteMessage(id, options = {}) {
return request.delete(`/messages/${id}`, {}, options)
}
/**
* 获取用户信息(基础信息缓存)
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getUserInfo(options = { cache: true, cacheTime: 30 * 60 * 1000 }) {
return request.get('/member/info', {}, options)
}
/**
* 获取会员详细信息(包含会员卡、积分等)
* @param {object} options - 请求选项 { cache: boolean }
*/
export function getMemberDetail(options = { cache: false }) {
return request.get('/member/info', {}, options)
}
/**
* 获取签到统计
* @param {object} params - 查询参数 { startDate: string, endDate: string }
* @param {object} options - 请求选项 { cache: boolean }
*/
export function getCheckInStats(params = {}, options = { cache: false }) {
return request.get('/checkIn/statistics', params, options)
}
/**
* 更新用户信息
* @param {object} params - 用户信息参数
*/
export function updateUserInfo(params) {
return request.put('/member/info', params)
}
// ========== 系统配置相关API ==========
/**
* 根据配置键获取配置值
* @param {string} configKey - 配置键
* @param {object} options - 请求选项
*/
export function getConfigByKey(configKey, options = {}) {
return request.get(`/config/key/${configKey}`, {}, options)
}
// ========== 课程相关API ==========
/**
* 获取推荐课程列表
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getRecommendCourses(options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/course/recommend', {}, options)
}
/**
* 获取课程详情
* @param {number} id - 课程ID
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/course/${id}`, {}, options)
}
/**
* 获取团课列表(分页)
* @param {object} params - 查询参数 { page: number, size: number, sort: string, order: string, keyword: string }
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
}
// ========== 团课预约相关API ==========
/**
* 预约团课
* @param {object} params - 预约参数
* @param {number} params.courseId - 团课ID
* @param {number} params.memberId - 会员ID
*/
export function bookGroupCourse(params) {
return request.post('/groupCourse/book', params)
}
/**
* 取消预约
* @param {number} bookingId - 预约ID
*/
export function cancelBooking(bookingId) {
return request.delete(`/groupCourse/book/${bookingId}`, {})
}
/**
* 团课签到
* @param {number} memberId - 会员ID
* @param {number} courseId - 团课ID
*/
export function signinGroupCourse(memberId, courseId) {
return request.post(`/groupCourse/signin/${memberId}`, { courseId })
}
/**
* 查询会员预约记录
* @param {number} memberId - 会员ID
*/
export function getMemberBookings(memberId, options = { cache: false }) {
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
}
/**
* 查询课程预约记录
* @param {number} courseId - 团课ID
*/
export function getCourseBookings(courseId, options = { cache: false }) {
return request.get(`/groupCourse/bookings/course/${courseId}`, {}, options)
}
// ========== 轮播图相关API ==========
/**
* 获取启用的轮播图列表
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getActiveBanners(options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/banner/active', {}, options)
}
export default {
login,
sendCode,
loginWithPhone,
oneClickLogin,
logout,
getQRCode,
checkIn,
getCheckInRecords,
getCheckInStats,
getUnreadMessageCount,
getUserMessages,
getUnreadMessages,
markMessageAsRead,
markAllMessagesAsRead,
deleteMessage,
getUserInfo,
getMemberDetail,
updateUserInfo,
getConfigByKey,
getRecommendCourses,
getCourseDetail,
getGroupCoursePage,
bookGroupCourse,
cancelBooking,
signinGroupCourse,
getMemberBookings,
getCourseBookings,
getActiveBanners
}
@@ -1,188 +0,0 @@
// common/constants/routes.js
/** 与 pages.json 保持一致 */
export const PAGE = {
INDEX: '/pages/index/index',
COURSE: '/pages/course/index',
MEMBER: '/pages/memberInfo/memberInfo',
BOOKING: '/pages/memberInfo/booking',
USER_INFO: '/pages/memberInfo/userInfo',
BODY_TEST_HOME: '/pages/memberInfo/bodyTestHome',
BODY_TEST_CONNECT: '/pages/memberInfo/bodyTestConnect',
BODY_TEST_MEASURING: '/pages/memberInfo/bodyTestMeasuring',
BODY_TEST_REPORT: '/pages/memberInfo/bodyTestReport',
BODY_TEST_HISTORY: '/pages/memberInfo/bodyTestHistory',
BODY_TEST_COMPARE: '/pages/memberInfo/bodyTestCompare',
BODY_TEST_SETTINGS: '/pages/memberInfo/bodyTestSettings',
BODY_TEST_TREND: '/pages/memberInfo/bodyTestTrend',
COURSE_LIST: '/pages/groupCourse/list',
COURSE_DETAIL: '/pages/memberInfo/courseDetail',
COUPON_DETAIL: '/pages/memberInfo/couponDetail',
COUPON_CENTER: '/pages/memberInfo/couponCenter',
POINTS_MALL: '/pages/memberInfo/pointsMall',
POINTS_HISTORY: '/pages/memberInfo/pointsHistory',
ONLINE_COURSE: '/pages/memberInfo/onlineCourseDetail',
COURSE_EVALUATE: '/pages/memberInfo/courseEvaluate',
TRAIN_SESSION: '/pages/memberInfo/trainSessionDetail',
TRAIN_REPORT: '/pages/memberInfo/trainReport',
COUPONS: '/pages/memberInfo/coupons',
POINTS: '/pages/memberInfo/points',
REFERRAL: '/pages/memberInfo/referral',
MY_COURSES: '/pages/memberInfo/myCourses',
CHECK_IN_HISTORY: '/pages/memberInfo/checkInHistory'
}
/** 底部 TabBar 页面路径,顺序与 TabBar.vue 一致 */
export const TAB_ROUTES = [
PAGE.INDEX,
PAGE.COURSE,
PAGE.MEMBER
]
const TAB_PAGES = new Set(TAB_ROUTES)
/** 防止 Tab 连点触发并发路由 */
let tabNavigating = false
function normalizePath(url) {
if (!url) return ''
const path = url.split('?')[0]
return path.startsWith('/') ? path : `/${path}`
}
export function getTabIndexByRoute(route) {
const path = normalizePath(route)
const idx = TAB_ROUTES.indexOf(path)
return idx >= 0 ? idx : 0
}
export function getCurrentRoutePath() {
const pages = getCurrentPages()
if (!pages.length) return PAGE.INDEX
const page = pages[pages.length - 1]
const route = page.route || page.$page?.fullPath || ''
return normalizePath(route ? `/${route}` : PAGE.INDEX)
}
/**
* 跳转到普通页面(非 TabBar 页面)
* 使用 navigateTo,保留页面栈,可以正常返回
*/
export function navigateToPage(url) {
uni.showLoading({ title: '加载中...', mask: true })
const path = normalizePath(url)
// ✅ 如果目标是 TabBar 页面,不应该使用 navigateTo
// 这种情况应该使用 switchToTabPage(会清空页面栈)
if (TAB_PAGES.has(path)) {
console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage')
uni.hideLoading()
switchToTabPage(path)
return
}
console.log('[navigateToPage] 跳转到:', url)
uni.navigateTo({
url,
fail: (err) => {
console.error('[navigateTo]', url, err)
uni.hideLoading()
// 页面栈满时降级使用 redirectTo
if (err.errMsg && err.errMsg.includes('limit')) {
uni.redirectTo({ url })
} else {
uni.showToast({ title: '页面跳转失败', icon: 'none' })
}
},
complete: () => {
// 页面已发起跳转,隐藏 loading
// 目标页面的 onLoad/onReady 也会调用 hideLoading 做兜底
uni.hideLoading()
}
})
}
/**
* 跳转到 TabBar 页面(清空页面栈)
* 用于从任何页面跳转到首页/课程/训练等 TabBar 页面
*/
export function switchToTabPage(url) {
const path = normalizePath(url)
if (!TAB_PAGES.has(path)) {
console.warn('[switchToTabPage] 目标不是 TabBar 页面:', path)
navigateToPage(url)
return
}
if (getCurrentRoutePath() === path || tabNavigating) return
console.log('[switchToTabPage] 跳转到 TabBar:', path)
tabNavigating = true
uni.switchTab({
url: path,
complete: () => {
uni.hideLoading()
setTimeout(() => {
tabNavigating = false
}, 320)
},
fail: (err) => {
console.error('[switchTab]', path, err)
uni.hideLoading()
uni.reLaunch({
url: path,
complete: () => {
uni.hideLoading()
setTimeout(() => {
tabNavigating = false
}, 320)
}
})
}
})
}
/**
* 重置到 TabBar 页面(清空所有历史)
* 用于退出登录、强制跳转等场景
*/
export function reLaunchToTabPage(url) {
const path = normalizePath(url)
console.log('[reLaunchToTabPage] 重置到:', path)
uni.reLaunch({
url: path,
fail: (err) => {
console.error('[reLaunch]', path, err)
uni.switchTab({ url: path })
}
})
}
/**
* 返回上一页,如果没有上一页则跳转到指定 TabBar 页面
*/
export function goBackOrTab(fallbackUrl = PAGE.MEMBER) {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
} else {
switchToTabPage(fallbackUrl)
}
}
/**
* 子页面返回个人中心
*/
export function backToMemberCenter() {
goBackOrTab(PAGE.MEMBER)
}
/**
* 子页面返回指定 TabBar 页面
*/
export function backToTab(tabUrl) {
goBackOrTab(tabUrl)
}
@@ -1,247 +0,0 @@
/**
* 登录拦截Hook
* 用于在需要登录的操作前检查用户登录状态,未登录则弹出登录框
*
* 使用方式:
* import { useLoginGuard } from '@/common/hooks/useLoginGuard.js'
*
* const { checkLogin, requireLogin } = useLoginGuard()
*
* // 方式1:检查登录状态(返回布尔值)
* if (!checkLogin()) return
*
* // 方式2:需要登录才执行(推荐)
* await requireLogin()
* // 之后的代码只有在登录后才执行
*
* // 方式3:在点击事件中使用
* async function handleClick() {
* await requireLogin()
* // 执行需要登录的操作
* }
*/
import { getToken } from '@/utils/request.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
// 保存当前页面的登录弹窗引用
let loginModalRef = null
/**
* 注册登录弹窗组件
* 在App.vue或页面onMounted中调用
* @param {Object} modalRef - loginModal组件的ref
*/
export function registerLoginModal(modalRef) {
loginModalRef = modalRef
}
/**
* 获取当前登录状态
* @returns {Boolean}
*/
export function isLoggedIn() {
const token = getToken()
const isLoginStorage = uni.getStorageSync('isLogin')
return !!(token || isLoginStorage)
}
/**
* 获取登录用户信息
* @returns {Object|null}
*/
export function getLoginUser() {
const store = loadMemberStore()
return store.profile || null
}
/**
* 使用登录拦截Hook
* @returns {Object}
*/
export function useLoginGuard() {
/**
* 检查登录状态(同步)
* @returns {Boolean} 是否已登录
*/
function checkLogin() {
return isLoggedIn()
}
/**
* 需要登录才继续执行(异步)
* 如果未登录,弹出登录框,等待用户登录或取消
* @param {Object} options - 配置选项
* @param {String} options.title - 弹窗标题
* @param {String} options.subtitle - 弹窗副标题
* @param {Function} options.onSuccess - 登录成功回调
* @param {Function} options.onCancel - 取消登录回调
* @returns {Promise<Boolean>} 是否已登录(用户登录成功返回true,取消返回false)
*/
async function requireLogin(options = {}) {
// 如果已经登录,直接返回true
if (isLoggedIn()) {
return true
}
// 如果传入了onSuccess回调,先检查
if (options.onSuccess) {
const store = loadMemberStore()
if (store.profile?.id) {
options.onSuccess(store.profile)
return true
}
}
// 显示登录弹窗
return new Promise((resolve) => {
if (!loginModalRef) {
console.warn('[useLoginGuard] 登录弹窗未注册,请先调用 registerLoginModal')
// 尝试跳转到登录页
uni.navigateTo({
url: '/pages/memberInfo/login',
fail: () => {
uni.showToast({
title: '请先登录',
icon: 'none'
})
}
})
resolve(false)
return
}
// 保存resolve函数
let resolvePromise = resolve
// 显示登录弹窗
loginModalRef.show()
// 监听登录成功
const loginSuccessHandler = (result) => {
if (options.onSuccess) {
options.onSuccess(result)
}
resolvePromise(true)
}
// 监听登录失败/取消
const closeHandler = () => {
if (options.onCancel) {
options.onCancel()
}
resolvePromise(false)
}
// 注册事件监听
uni.$once('loginModal:success', loginSuccessHandler)
uni.$once('loginModal:close', closeHandler)
// 设置超时
setTimeout(() => {
uni.$off('loginModal:success', loginSuccessHandler)
uni.$off('loginModal:close', closeHandler)
}, 60000) // 60秒超时
})
}
/**
* 安全执行函数(仅登录后执行)
* @param {Function} fn - 要执行的函数
* @param {Object} options - 配置选项
* @returns {Promise<any>} 函数返回值或null
*/
async function safeExecute(fn, options = {}) {
const loggedIn = await requireLogin(options)
if (loggedIn && typeof fn === 'function') {
return await fn()
}
return null
}
/**
* 包装需要登录的API调用
* @param {Function} apiFn - API函数
* @param {Array} args - API参数
* @param {Object} options - 配置选项
* @returns {Promise<any>}
*/
async function callLoggedInApi(apiFn, args = [], options = {}) {
const loggedIn = await requireLogin(options)
if (loggedIn && typeof apiFn === 'function') {
return await apiFn(...args)
}
throw new Error('用户未登录')
}
return {
checkLogin,
requireLogin,
safeExecute,
callLoggedInApi,
isLoggedIn,
getLoginUser
}
}
/**
* 创建登录拦截Mixin(用于选项式API)
* @returns {Object}
*/
export function loginGuardMixin() {
return {
data() {
return {
isLogin: false
}
},
onLoad() {
this.checkLoginStatus()
},
onShow() {
this.checkLoginStatus()
},
methods: {
checkLoginStatus() {
this.isLogin = isLoggedIn()
},
async ensureLogin() {
if (!this.isLogin) {
if (!loginModalRef) {
uni.navigateTo({
url: '/pages/memberInfo/login'
})
return false
}
loginModalRef.show()
return false
}
return true
}
}
}
}
/**
* 路由守卫配置
* 配置需要登录的页面路径
*/
export const protectedRoutes = [
'/pages/memberInfo/memberInfo',
'/pages/memberInfo/checkIn',
'/pages/memberInfo/booking',
'/pages/memberInfo/coupons',
'/pages/memberInfo/points',
'/pages/memberInfo/userInfo'
]
/**
* 检查当前页面是否需要登录
* @param {String} currentPage - 当前页面路径
* @returns {Boolean}
*/
export function isProtectedRoute(currentPage) {
return protectedRoutes.some(route => currentPage.includes(route))
}
@@ -1,163 +0,0 @@
const COLORS = {
primary: '#0B2B4B',
accent: '#FF6B35',
accentLight: 'rgba(255, 107, 53, 0.25)',
grid: '#E9EDF2',
text: '#5E6F8D',
fill: 'rgba(26, 74, 111, 0.35)',
line: '#1A4A6F'
}
function setupCanvas(canvas, width, height, dpr) {
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
return ctx
}
/** 绘制雷达图 */
export function drawRadarChart(canvas, opts = {}) {
if (!canvas) return
const {
width = 280,
height = 240,
labels = [],
values = [],
dpr = 1
} = opts
const ctx = setupCanvas(canvas, width, height, dpr)
ctx.clearRect(0, 0, width, height)
const cx = width / 2
const cy = height / 2 + 8
const radius = Math.min(width, height) * 0.32
const count = labels.length || 6
const angleStep = (Math.PI * 2) / count
for (let level = 1; level <= 4; level += 1) {
ctx.beginPath()
const r = (radius * level) / 4
for (let i = 0; i <= count; i += 1) {
const angle = -Math.PI / 2 + i * angleStep
const x = cx + r * Math.cos(angle)
const y = cy + r * Math.sin(angle)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.strokeStyle = COLORS.grid
ctx.lineWidth = 1
ctx.stroke()
}
for (let i = 0; i < count; i += 1) {
const angle = -Math.PI / 2 + i * angleStep
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle))
ctx.strokeStyle = COLORS.grid
ctx.stroke()
}
ctx.beginPath()
values.forEach((val, i) => {
const ratio = Math.min(1, Math.max(0, val / 100))
const angle = -Math.PI / 2 + i * angleStep
const x = cx + radius * ratio * Math.cos(angle)
const y = cy + radius * ratio * Math.sin(angle)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.closePath()
ctx.fillStyle = COLORS.fill
ctx.fill()
ctx.strokeStyle = COLORS.accent
ctx.lineWidth = 2
ctx.stroke()
ctx.font = '11px sans-serif'
ctx.fillStyle = COLORS.text
ctx.textAlign = 'center'
labels.forEach((label, i) => {
const angle = -Math.PI / 2 + i * angleStep
const x = cx + (radius + 18) * Math.cos(angle)
const y = cy + (radius + 18) * Math.sin(angle) + 4
ctx.fillText(label, x, y)
})
}
/** 绘制折线趋势图 */
export function drawTrendChart(canvas, opts = {}) {
if (!canvas) return
const {
width = 300,
height = 160,
points = [],
dpr = 1,
unit = ''
} = opts
const ctx = setupCanvas(canvas, width, height, dpr)
ctx.clearRect(0, 0, width, height)
if (!points.length) {
ctx.fillStyle = COLORS.text
ctx.font = '13px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('暂无趋势数据', width / 2, height / 2)
return
}
const pad = { top: 16, right: 12, bottom: 28, left: 12 }
const chartW = width - pad.left - pad.right
const chartH = height - pad.top - pad.bottom
const values = points.map((p) => p.value)
const min = Math.min(...values) * 0.95
const max = Math.max(...values) * 1.05
const range = max - min || 1
ctx.strokeStyle = COLORS.grid
ctx.lineWidth = 1
for (let i = 0; i <= 3; i += 1) {
const y = pad.top + (chartH * i) / 3
ctx.beginPath()
ctx.moveTo(pad.left, y)
ctx.lineTo(width - pad.right, y)
ctx.stroke()
}
const coords = points.map((p, i) => ({
x: pad.left + (chartW * i) / Math.max(1, points.length - 1),
y: pad.top + chartH - ((p.value - min) / range) * chartH
}))
ctx.beginPath()
coords.forEach((pt, i) => {
if (i === 0) ctx.moveTo(pt.x, pt.y)
else ctx.lineTo(pt.x, pt.y)
})
ctx.strokeStyle = COLORS.line
ctx.lineWidth = 2
ctx.stroke()
coords.forEach((pt, i) => {
ctx.beginPath()
ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2)
ctx.fillStyle = COLORS.accent
ctx.fill()
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1.5
ctx.stroke()
ctx.fillStyle = COLORS.text
ctx.font = '10px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(points[i].label, pt.x, height - 8)
})
if (unit) {
ctx.fillStyle = COLORS.text
ctx.font = '10px sans-serif'
ctx.textAlign = 'left'
ctx.fillText(unit, pad.left, pad.top - 2)
}
}
@@ -1,286 +0,0 @@
function formatRecordTime(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}`
}
function formatIsoDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function formatTime(date) {
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${h}:${min}`
}
export function getDefaultBodyTestState() {
return {
settings: {},
device: { connected: false, battery: 80 },
records: []
}
}
export function mergeBodyTestState(saved) {
const defaults = getDefaultBodyTestState()
if (!saved) return defaults
return {
settings: { ...defaults.settings, ...(saved.settings || {}) },
device: { ...defaults.device, ...(saved.device || {}) },
records: saved.records?.length ? saved.records : defaults.records
}
}
export function getLatestBodyTestRecord(store) {
const records = store.bodyTest?.records || []
return records.length ? { ...records[0] } : null
}
export function getBodyTestRecordById(store, id) {
const numId = Number(id)
const record = (store.bodyTest?.records || []).find((item) => item.id === numId)
return record ? { ...record } : null
}
export function getBodyTestHistory(store, year) {
let list = (store.bodyTest?.records || []).map((item) => ({ ...item }))
if (year && year !== 'all') {
list = list.filter((r) => r.date.startsWith(String(year)))
}
return list
}
export function getBodyTestChangeBadge(record, previous) {
if (!previous?.metrics || !record?.metrics) return null
const diff = Math.round((record.metrics.bodyFat - previous.metrics.bodyFat) * 10) / 10
if (diff === 0) return null
const sign = diff > 0 ? '+' : ''
return { key: 'bodyFat', text: `体脂率${sign}${diff}%`, good: diff < 0 }
}
export function getBodyTestYears(store) {
const years = new Set((store.bodyTest?.records || []).map((r) => r.date.slice(0, 4)))
return ['all', ...Array.from(years).sort().reverse()]
}
export function computeGrade(score) {
if (score >= 90) return { grade: 'A', gradeLabel: '优秀' }
if (score >= 80) return { grade: 'B+', gradeLabel: '良好' }
if (score >= 70) return { grade: 'B', gradeLabel: '中等' }
if (score >= 60) return { grade: 'C', gradeLabel: '一般' }
return { grade: 'D', gradeLabel: '需改善' }
}
export function computeScore(metrics) {
const bmi = metrics.bmi || 22
const bodyFat = metrics.bodyFat || 25
const muscle = metrics.muscleMass || 22
const bmiScore = bmi >= 18.5 && bmi <= 24 ? 90 : bmi >= 17 && bmi <= 27 ? 75 : 60
const fatScore = bodyFat <= 22 ? 92 : bodyFat <= 26 ? 80 : bodyFat <= 30 ? 68 : 55
const muscleScore = muscle >= 22 ? 88 : muscle >= 20 ? 76 : 62
return Math.round((bmiScore + fatScore + muscleScore) / 3)
}
export function computeChanges(current, previous) {
if (!previous?.metrics) return {}
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr', 'bodyWater', 'boneMass']
const changes = {}
keys.forEach((key) => {
const cur = Number(current.metrics[key])
const prev = Number(previous.metrics[key])
if (Number.isFinite(cur) && Number.isFinite(prev)) {
const diff = Math.round((cur - prev) * 10) / 10
changes[key] = diff
}
})
return changes
}
export function formatChangeValue(key, diff, unitSystem = 'metric') {
if (diff === undefined || diff === null) return '--'
const sign = diff > 0 ? '+' : ''
const units = {
weight: unitSystem === 'metric' ? 'kg' : 'lb',
bodyFat: '%',
muscleMass: 'kg',
bmi: '',
visceralFat: '级',
bmr: 'kcal',
bodyWater: '%',
boneMass: 'kg'
}
const unit = units[key] || ''
return `${sign}${diff}${unit}`
}
export function buildBodyReportSummary(record, previous) {
if (!record) {
return {
date: '--',
weight: '--',
bmi: '--',
bodyFat: '--',
bmr: '--',
status: '暂无数据',
change: '--'
}
}
const changes = computeChanges(record, previous)
const weightChange = changes.weight
let changeText = '--'
if (weightChange !== undefined) {
const sign = weightChange > 0 ? '+' : ''
changeText = `${sign}${weightChange}kg`
}
return {
date: record.date,
weight: String(record.metrics.weight),
bmi: String(record.metrics.bmi),
bodyFat: `${record.metrics.bodyFat}%`,
bmr: String(record.metrics.bmr),
status: record.status,
change: changeText,
recordId: record.id
}
}
export function getBodyTestTrendData(store, metricKey, limit = 6) {
const records = [...(store.bodyTest?.records || [])].reverse().slice(-limit)
return records.map((item) => ({
id: item.id,
date: item.date,
label: item.date.slice(5),
value: Number(item.metrics[metricKey]) || 0
}))
}
function getMetricDefs() {
return [
{ key: 'weight', label: '体重' },
{ key: 'bmi', label: 'BMI' },
{ key: 'bodyFat', label: '体脂率' },
{ key: 'muscleMass', label: '肌肉量' },
{ key: 'visceralFat', label: '内脏脂肪' },
{ key: 'bmr', label: '基础代谢' }
]
}
export function getCompareData(store, idA, idB) {
const a = getBodyTestRecordById(store, idA)
const b = getBodyTestRecordById(store, idB)
if (!a || !b) return null
const metricDefs = getMetricDefs()
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr']
const metrics = keys.map((key) => ({
key,
label: metricDefs.find((m) => m.key === key)?.label || key,
valueA: a.metrics[key],
valueB: b.metrics[key],
diff: Math.round((a.metrics[key] - b.metrics[key]) * 10) / 10
}))
return { recordA: a, recordB: b, metrics }
}
export function getRecommendedCourses(record) {
return []
}
export function updateBodyTestSettings(store, patch) {
store.bodyTest.settings = { ...store.bodyTest.settings, ...patch }
return store
}
export function connectBodyTestDevice(store) {
store.bodyTest.device = {
...store.bodyTest.device,
connected: true,
battery: Math.min(100, (store.bodyTest.device.battery || 80) + Math.floor(Math.random() * 5)),
lastConnected: formatRecordTime(new Date())
}
return store
}
export function disconnectBodyTestDevice(store) {
store.bodyTest.device = { ...store.bodyTest.device, connected: false }
return store
}
function nextRecordId(records) {
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
}
/** 模拟一次完整体测并写入记录 */
export function saveSimulatedBodyTestRecord(store, finalMetrics) {
const now = new Date()
const previous = getLatestBodyTestRecord(store)
const metrics = { ...finalMetrics }
const heightCm = Number(store.profile?.height) || 165
const heightM = heightCm / 100
metrics.bmi = Math.round((metrics.weight / (heightM * heightM)) * 10) / 10
const score = computeScore(metrics)
const { grade, gradeLabel } = computeGrade(score)
const status = score >= 80 ? '比较健康' : score >= 70 ? '需关注' : '建议改善'
const radar = {
weight: Math.min(95, Math.round(score * 0.9 + Math.random() * 5)),
bodyFat: Math.min(95, Math.round(100 - metrics.bodyFat * 2.5)),
muscle: Math.min(95, Math.round(metrics.muscleMass * 3.2)),
bone: Math.min(95, Math.round(metrics.boneMass * 32)),
water: Math.min(95, Math.round(metrics.bodyWater * 1.4)),
bmr: Math.min(95, Math.round(metrics.bmr / 16))
}
const record = {
id: nextRecordId(store.bodyTest.records),
date: formatIsoDate(now),
time: formatTime(now),
score,
grade,
gradeLabel,
status,
metrics,
radar,
bodySegments: [],
advice: [],
recommendedCourseIds: []
}
if (previous) {
record.changes = computeChanges(record, previous)
}
store.bodyTest.records.unshift(record)
store.bodyReport = buildBodyReportSummary(record, previous)
return record
}
/** 测量过程实时数据插值 */
export function interpolateMeasuringMetrics(progress, profile) {
const baseWeight = Number(profile?.weight) || 64
const target = {
weight: baseWeight - 0.3 + Math.random() * 0.2,
bodyFat: 24.5 + Math.random() * 0.8,
muscleMass: 22.4 + Math.random() * 0.3,
visceralFat: 6,
bmr: 1380 + Math.floor(Math.random() * 20),
bodyWater: 52.5 + Math.random(),
boneMass: 2.4,
protein: 16.2
}
const ratio = Math.min(1, progress / 100)
return {
weight: Math.round((baseWeight + (target.weight - baseWeight) * ratio) * 10) / 10,
bodyFat: Math.round((26 + (target.bodyFat - 26) * ratio) * 10) / 10,
muscleMass: Math.round((21.5 + (target.muscleMass - 21.5) * ratio) * 10) / 10,
bmr: Math.round(1340 + (target.bmr - 1340) * ratio),
bodyWater: Math.round((51 + (target.bodyWater - 51) * ratio) * 10) / 10
}
}
@@ -1,135 +0,0 @@
import { courseCatalogMock } from './mockData.js'
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
export function getDefaultCourseCatalog() {
return clone(courseCatalogMock.courses)
}
export function mergeCourseCatalog(saved) {
const defaults = getDefaultCourseCatalog()
if (!saved?.length) return defaults
return saved.map((item, i) => ({ ...defaults[i], ...item }))
}
function parseCourseStart(course) {
const str = `${course.date} ${course.startTime}`.replace(/-/g, '/')
return new Date(str)
}
function getPeriod(hour) {
if (hour < 12) return 'morning'
if (hour < 18) return 'afternoon'
return 'evening'
}
export function filterCourses(courses, filters = {}) {
const {
date = '',
weekDates = [],
type = 'all',
coach = '全部',
period = 'all'
} = filters
return courses.filter((c) => {
if (type !== 'all' && c.type !== type) return false
if (coach !== '全部' && c.coach !== coach) return false
if (period !== 'all' && c.period !== period) return false
if (date && c.date !== date) {
if (!weekDates.length || !weekDates.includes(c.date)) return false
}
return true
})
}
export function getCourseById(store, id) {
const course = (store.courseCatalog || []).find((c) => c.id === Number(id))
return course ? { ...course } : null
}
export function canCancelBooking(item) {
if (!item?.courseDate || !item?.startTime) return !!item?.canCancel
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
const diff = start - Date.now()
return diff >= 2 * 3600000
}
export function canSigninCourse(item) {
if (!item?.courseDate || !item?.startTime) return false
if (item.status !== 'booked' && item.status !== 0) return false
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
const diff = start - Date.now()
return diff <= 2 * 3600000 && diff >= -2 * 3600000
}
export function bookCourse(store, courseId) {
const course = store.courseCatalog.find((c) => c.id === Number(courseId))
if (!course) return { ok: false, message: '课程不存在' }
if (course.enrolled >= course.capacity) return { ok: false, message: '课程已约满' }
const exists = store.ongoingBookings.some((b) => b.courseId === course.id)
if (exists) return { ok: false, message: '您已预约该课程' }
course.enrolled += 1
const nextId = store.ongoingBookings.reduce((m, b) => Math.max(m, b.id || 0), 0) + 1
const parts = course.date.split('-')
const booking = {
id: nextId,
courseId: course.id,
title: course.title,
banner: course.banner,
status: 'booked',
statusLabel: '已预约',
schedule: `${parts[1]}${parts[2]}${course.startTime}-${course.endTime}`,
dateDay: parts[2],
dateMonth: `${parts[2]}`,
timeRange: `${course.startTime}-${course.endTime}`,
courseDate: course.date,
startTime: course.startTime,
coach: course.coach,
coachShort: course.coach.replace('教练', ''),
location: course.location,
footerText: `可取消(需提前2小时,截止 ${parts[1]}/${parts[2]} ${course.startTime} 前2小时)`,
canCancel: true,
type: course.type
}
store.ongoingBookings.unshift(booking)
return { ok: true, message: '预约成功', booking }
}
export function getWeekDates(baseDateStr) {
const base = baseDateStr ? new Date(baseDateStr.replace(/-/g, '/')) : new Date()
const day = base.getDay() || 7
const monday = new Date(base)
monday.setDate(base.getDate() - day + 1)
const dates = []
for (let i = 0; i < 7; i += 1) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
dates.push(formatIso(d))
}
return dates
}
function formatIso(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function enrichCourseForDisplay(course) {
const remaining = course.capacity - course.enrolled
const percent = Math.round((course.enrolled / course.capacity) * 100)
return {
...course,
remaining,
percent,
full: remaining <= 0,
scarcityLabel: remaining > 0 && remaining <= 5 ? `仅剩${remaining}` : ''
}
}
export { courseCatalogMock }
@@ -1,37 +0,0 @@
/** 手机号展示脱敏(中间四位 ****) */
export function maskPhone(phone) {
if (phone == null || phone === '') return ''
const str = String(phone).trim()
if (str.includes('****')) return str
const digits = str.replace(/\D/g, '')
if (digits.length === 11) {
return `${digits.slice(0, 3)}****${digits.slice(7)}`
}
if (digits.length > 4) {
const hideLen = Math.min(4, digits.length - 3)
const start = Math.floor((digits.length - hideLen) / 2)
return `${digits.slice(0, start)}${'*'.repeat(hideLen)}${digits.slice(start + hideLen)}`
}
return str
}
/** 个人中心头部:138****6789 已绑定微信 */
export function formatMemberCenterPhone(phone) {
const masked = maskPhone(phone)
return masked ? `${masked} 已绑定微信` : ''
}
/** 保存前规范化:尽量存 11 位数字;已是脱敏串则原样保留 */
export function normalizePhoneForStore(phone) {
const str = String(phone || '').trim()
if (!str) return ''
if (str.includes('****')) return str
const digits = str.replace(/\D/g, '')
if (digits.length >= 11) return digits.slice(0, 11)
return digits || str
}
@@ -1,78 +0,0 @@
export { statusBarTimeMixin, subPageMixin } from './mixins.js'
export {
loadMemberStore,
saveMemberStore,
persistMemberStore,
syncStats,
computeRemainingDays,
buildCardTip,
formatUpcomingAlert,
getBookingPreview,
getCenterPageData,
cancelOngoingBooking,
renewMemberCard,
parseLocalDate,
saveUserProfile,
getCurrentMemberId,
getLoginMemberInfo,
getToken
} from './store.js'
export {
getLatestBodyTestRecord,
getBodyTestRecordById,
getBodyTestHistory,
computeChanges,
formatChangeValue,
buildBodyReportSummary,
getBodyTestTrendData,
getCompareData,
getRecommendedCourses,
getBodyTestChangeBadge,
getBodyTestYears,
updateBodyTestSettings,
connectBodyTestDevice,
disconnectBodyTestDevice,
saveSimulatedBodyTestRecord,
interpolateMeasuringMetrics
} from './bodyTestStore.js'
export {
getTrainingReportData,
getTrainingSessionById,
filterTrainingSessions,
getCouponsByStatus,
getCouponById,
useCoupon,
deleteExpiredCoupon,
getCouponCenterList,
claimCouponFromCenter,
getPointsPageData,
redeemPointsReward,
filterPointsHistory,
getReferralPageData,
getMyCoursesData,
getMyCoursesByTab,
getOnlineCourseById,
updateOnlineProgress,
getCheckInHistory
} from './moduleStore.js'
export {
filterCourses,
getCourseById,
bookCourse,
canCancelBooking,
enrichCourseForDisplay,
getWeekDates
} from './bookingStore.js'
export { previewImage, persistChosenImage, isLocalFilePath } from './media.js'
export { maskPhone, formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
export {
validateName,
validatePhone,
validatePhoneForRebind,
validateHeight,
validateWeight,
validateBirthday,
validateFitnessGoals,
validateUserProfile,
showValidationError
} from './validate.js'
@@ -1,159 +0,0 @@
/** 头像等媒体:真机选图后须 saveFile/static/ 须 getImageInfo */
function buildStaticPathCandidates(url) {
const list = [url]
if (url.startsWith('/')) {
list.push(url.slice(1))
} else {
list.push(`/${url}`)
}
return [...new Set(list.filter(Boolean))]
}
function isPackageStaticPath(url) {
return /^(\/)?static\//i.test(url)
}
/** chooseImage / saveFile 产生的本地路径(含真机 temp、usr、store */
export function isLocalFilePath(url) {
if (!url) return false
if (/^(wxfile:|file:|blob:|data:)/i.test(url)) return true
if (/^https?:\/\/(tmp|usr|store)\//i.test(url)) return true
if (/^https?:\/\//i.test(url) && !isPackageStaticPath(url)) return true
return false
}
function showPreviewFail() {
uni.showToast({ title: '无法预览头像', icon: 'none' })
}
function openPreview(path, onFail) {
if (!path) {
;(onFail || showPreviewFail)()
return
}
uni.previewImage({
urls: [path],
current: path,
fail: () => (onFail ? onFail() : showPreviewFail())
})
}
function previewLocalFile(url) {
openPreview(url, () => {
uni.getImageInfo({
src: url,
success: (res) => {
openPreview(res.path || url, showPreviewFail)
},
fail: showPreviewFail
})
})
}
function tryGetImageInfo(candidates, index, onSuccess, onFail) {
if (index >= candidates.length) {
onFail()
return
}
uni.getImageInfo({
src: candidates[index],
success: (res) => onSuccess(res.path || candidates[index]),
fail: () => tryGetImageInfo(candidates, index + 1, onSuccess, onFail)
})
}
function getMpUserDataPath() {
// #ifdef MP-WEIXIN
return wx.env.USER_DATA_PATH
// #endif
return ''
}
function tryCopyFile(candidates, index, onSuccess, onFail) {
// #ifdef MP-WEIXIN
const userPath = getMpUserDataPath()
if (!userPath) {
onFail()
return
}
const fs = uni.getFileSystemManager()
const extMatch = candidates[0]?.match(/\.(\w+)(?:\?|$)/)
const ext = extMatch ? extMatch[1] : 'png'
const dest = `${userPath}/avatar_preview_${Date.now()}.${ext}`
if (index >= candidates.length) {
onFail()
return
}
fs.copyFile({
srcPath: candidates[index],
destPath: dest,
success: () => onSuccess(dest),
fail: () => tryCopyFile(candidates, index + 1, onSuccess, onFail)
})
// #endif
// #ifndef MP-WEIXIN
onFail()
// #endif
}
function previewPackageStatic(url) {
const candidates = buildStaticPathCandidates(url)
tryGetImageInfo(
candidates,
0,
(path) => openPreview(path, showPreviewFail),
() => {
tryCopyFile(
candidates,
0,
(path) => openPreview(path, showPreviewFail),
showPreviewFail
)
}
)
}
/** 选图后将临时文件转为真机可预览、可持久化的本地路径 */
export function persistChosenImage(tempPath) {
return new Promise((resolve) => {
const path = String(tempPath || '').trim()
if (!path) {
resolve('')
return
}
// #ifdef MP-WEIXIN
uni.saveFile({
tempFilePath: path,
success: (res) => resolve(res.savedFilePath || path),
fail: () => resolve(path)
})
// #endif
// #ifndef MP-WEIXIN
resolve(path)
// #endif
})
}
export function previewImage(src, fallback = '') {
const url = String(src || fallback || '').trim()
if (!url) {
uni.showToast({ title: '暂无头像', icon: 'none' })
return
}
if (isLocalFilePath(url)) {
previewLocalFile(url)
return
}
if (isPackageStaticPath(url)) {
previewPackageStatic(url)
return
}
previewLocalFile(url)
}
@@ -1,28 +0,0 @@
import { backToMemberCenter } from '../constants/routes.js'
/** 状态栏时间(Pixso 顶栏占位) */
export const statusBarTimeMixin = {
data() {
return {
statusBarTime: '9:41'
}
},
onLoad() {
this.updateStatusBarTime()
},
methods: {
updateStatusBarTime() {
const now = new Date()
this.statusBarTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
}
}
}
/** 子页面返回个人中心 tab */
export const subPageMixin = {
methods: {
goBack() {
backToMemberCenter()
}
}
}
@@ -1,887 +0,0 @@
/** 个人中心模块 mock 数据(后续可替换为 API) */
// Mock 数据开关 - 设为 false 可关闭所有 mock 数据
export const MOCK_ENABLED = false
export const memberCenterMock = MOCK_ENABLED ? {
userInfo: {
name: '张小芳',
phone: '13812345678 已绑定微信',
memberLevel: '黄金会员',
avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
},
stats: {
checkInCount: 128,
trainingHours: 23,
pointsBalance: 1250
},
cardInfo: {
name: '健身时长卡',
detailTag: '详情',
expireDate: '有效期至 2025年12月31日',
remainingDays: 187,
tip: '距离下次到期还有187天,请及时续费'
},
checkIns: [
{
id: 1,
title: '今日签到 · 瑜伽初级班',
time: '2024-07-12 09:05',
tag: '团课',
tagTheme: 'group'
},
{
id: 2,
title: '自由训练 · 进馆记录',
time: '2024-07-11 18:30',
tag: '自由',
tagTheme: 'free'
},
{
id: 3,
title: '私教课 · 力量训练',
time: '2024-07-10 14:00',
tag: '私教',
tagTheme: 'private'
}
],
bodyReport: {
date: '2024-07-01',
weight: '63.5',
bmi: '22.1',
bodyFat: '24.8%',
bmr: '165',
status: '比较健康',
change: '-1.2kg'
},
couponPoints: {
amount: '¥50',
couponDesc: '满500可用 · 1张',
couponAction: '去使用',
points: 1250,
pointsLabel: '我的积分',
pointsAction: '去兑换'
},
referral: {
code: 'FIT-ZXF-2024',
invited: 5,
registered: 3,
purchased: 2
}
} : null
export const userInfoMock = {
name: '张小芳',
phone: '13812345678',
gender: 'female',
birthday: '1995年06月15日',
height: '165',
weight: '63.5',
fitnessGoals: ['减脂', '塑形'],
avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
}
export const fitnessGoalOptions = ['减脂', '塑形', '增肌', '提升耐力', '改善体态']
export const memberCardMock = {
card: {
name: '黄金健身时长卡',
status: '生效中',
validityStart: '2024年01月01日',
validity: '2024年01月01日 - 2025年12月31日',
validityEnd: '2025-12-31',
remainingDays: 187
},
recordTabs: [
{ key: 'all', label: '全部' },
{ key: 'consume', label: '消费' },
{ key: 'checkin', label: '签到' }
],
records: [
{
id: 1,
type: 'checkin',
title: '瑜伽初级班 · 团课签到',
time: '2024-07-12 09:05',
value: '-1次',
valueType: 'negative',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png',
iconTheme: 'orange'
},
{
id: 2,
type: 'checkin',
title: '自由进馆',
time: '2024-07-11 18:30',
value: '-1天',
valueType: 'negative',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin.png',
iconTheme: 'green'
},
{
id: 3,
type: 'consume',
title: '会员卡充值',
time: '2024-07-01 10:00',
value: '+90天',
valueType: 'positive',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/pluscircle.png',
iconTheme: 'orange'
}
],
rules: [
'时长卡有效期内不限入场次数,但需提前预约团课',
'卡到期后不退余额,请合理安排使用',
'一卡仅限本人使用,不可转让'
]
}
/** 智能体测模块 mock 数据 */
export const bodyTestMock = {
settings: {
autoSync: true,
bluetoothEnabled: true,
notifyOnComplete: true,
shareAnonymous: false,
unitSystem: 'metric'
},
device: {
connected: false,
name: 'InBody 270',
model: 'IB-270',
battery: 86,
signal: 'strong',
lastConnected: '2024-07-10 18:20'
},
connectSteps: [
{ step: 1, title: '开启体测仪', desc: '长按电源键 3 秒,等待蓝牙指示灯闪烁' },
{ step: 2, title: '靠近设备', desc: '将手机靠近体测仪 1 米范围内' },
{ step: 3, title: '确认连接', desc: '点击下方按钮搜索并配对设备' }
],
metricDefs: [
{ key: 'weight', label: '体重', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/target.png' },
{ key: 'bmi', label: 'BMI', unit: '', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png' },
{ key: 'bodyFat', label: '体脂率', unit: '%', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/trendingdown.png' },
{ key: 'muscleMass', label: '肌肉量', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png' },
{ key: 'visceralFat', label: '内脏脂肪', unit: '级', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/alertcircle.png' },
{ key: 'bmr', label: '基础代谢', unit: 'kcal', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock.png' },
{ key: 'bodyWater', label: '体水分', unit: '%', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/shield.png' },
{ key: 'boneMass', label: '骨量', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user.png' }
],
radarLabels: [
{ key: 'weight', label: '体重控制' },
{ key: 'bodyFat', label: '体脂肪' },
{ key: 'muscle', label: '肌肉量' },
{ key: 'bone', label: '骨量' },
{ key: 'water', label: '体水分' },
{ key: 'bmr', label: '基础代谢' }
],
trendMetrics: [
{ key: 'weight', label: '体重' },
{ key: 'bodyFat', label: '体脂率' },
{ key: 'muscleMass', label: '肌肉量' },
{ key: 'bmi', label: 'BMI' }
],
recommendedCourses: [
{
id: 1,
title: '燃脂 HIIT 团课',
coach: '李明教练',
schedule: '每周二、四 19:00',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
tag: '减脂推荐'
},
{
id: 2,
title: '核心力量塑形',
coach: '王强教练',
schedule: '每周一、三 18:30',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
tag: '塑形推荐'
}
],
records: [
{
id: 4,
date: '2024-07-12',
time: '09:05',
score: 85,
grade: 'B+',
gradeLabel: '良好',
status: '比较健康',
bodyAge: 27,
realAge: 29,
metrics: {
weight: 63.5,
bmi: 22.1,
bodyFat: 24.8,
muscleMass: 22.6,
visceralFat: 6,
bmr: 1385,
bodyWater: 52.8,
boneMass: 2.42,
protein: 16.4
},
radar: { weight: 78, bodyFat: 72, muscle: 74, bone: 81, water: 79, bmr: 73 },
bodySegments: [
{ part: '左臂', level: 'normal', value: '2.1kg' },
{ part: '右臂', level: 'normal', value: '2.2kg' },
{ part: '躯干', level: 'high', value: '28.5kg' },
{ part: '左腿', level: 'normal', value: '8.6kg' },
{ part: '右腿', level: 'normal', value: '8.7kg' }
],
advice: [
'体脂率略高,建议增加有氧训练频率至每周 3-4 次',
'核心肌群表现良好,可尝试进阶力量课程',
'保持当前蛋白质摄入,有助于维持肌肉量'
],
recommendedCourseIds: [1, 2]
},
{
id: 3,
date: '2024-06-28',
time: '18:40',
score: 82,
grade: 'B+',
gradeLabel: '良好',
status: '比较健康',
bodyAge: 28,
realAge: 29,
metrics: {
weight: 64.7,
bmi: 22.5,
bodyFat: 25.3,
muscleMass: 22.2,
visceralFat: 7,
bmr: 1370,
bodyWater: 52.1,
boneMass: 2.4,
protein: 16.1
},
radar: { weight: 74, bodyFat: 68, muscle: 70, bone: 80, water: 76, bmr: 70 },
bodySegments: [
{ part: '左臂', level: 'normal', value: '2.0kg' },
{ part: '右臂', level: 'normal', value: '2.1kg' },
{ part: '躯干', level: 'high', value: '28.2kg' },
{ part: '左腿', level: 'normal', value: '8.5kg' },
{ part: '右腿', level: 'normal', value: '8.6kg' }
],
advice: [
'体重较上次下降 0.8kg,减脂方向正确',
'建议配合拉伸课程改善体态'
],
recommendedCourseIds: [1]
},
{
id: 2,
date: '2024-06-10',
time: '10:15',
score: 79,
grade: 'B',
gradeLabel: '中等',
status: '需关注',
bodyAge: 30,
realAge: 29,
metrics: {
weight: 65.5,
bmi: 22.8,
bodyFat: 26.1,
muscleMass: 21.8,
visceralFat: 8,
bmr: 1355,
bodyWater: 51.5,
boneMass: 2.38,
protein: 15.8
},
radar: { weight: 70, bodyFat: 62, muscle: 66, bone: 78, water: 72, bmr: 66 },
bodySegments: [
{ part: '左臂', level: 'low', value: '1.9kg' },
{ part: '右臂', level: 'normal', value: '2.0kg' },
{ part: '躯干', level: 'high', value: '28.0kg' },
{ part: '左腿', level: 'normal', value: '8.4kg' },
{ part: '右腿', level: 'normal', value: '8.5kg' }
],
advice: [
'内脏脂肪偏高,建议减少高糖饮食',
'增加抗阻训练提升肌肉量'
],
recommendedCourseIds: [2]
},
{
id: 1,
date: '2024-05-20',
time: '14:30',
score: 76,
grade: 'B',
gradeLabel: '中等',
status: '需关注',
bodyAge: 31,
realAge: 29,
metrics: {
weight: 66.2,
bmi: 23.1,
bodyFat: 26.8,
muscleMass: 21.5,
visceralFat: 9,
bmr: 1340,
bodyWater: 51.0,
boneMass: 2.35,
protein: 15.5
},
radar: { weight: 66, bodyFat: 58, muscle: 62, bone: 76, water: 68, bmr: 62 },
bodySegments: [
{ part: '左臂', level: 'low', value: '1.8kg' },
{ part: '右臂', level: 'low', value: '1.9kg' },
{ part: '躯干', level: 'high', value: '27.8kg' },
{ part: '左腿', level: 'normal', value: '8.3kg' },
{ part: '右腿', level: 'normal', value: '8.4kg' }
],
advice: [
'建议制定 8 周减脂计划并定期复测',
'每日饮水量建议达到 2000ml'
],
recommendedCourseIds: [1, 2]
}
]
}
export const bookingMock = {
upcomingAlert: '明天 09:00 有一堂瑜伽课,请提前 30 分钟到场',
tabs: [
{ key: 'ongoing', label: '进行中' },
{ key: 'history', label: '历史预约' }
],
ongoing: [
{
id: 1,
title: '瑜伽基础班',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
status: 'booked',
statusLabel: '已预约',
schedule: '07月15日 09:00-10:00',
dateDay: '07',
dateMonth: '月15日',
timeRange: '09:00-10:00',
coach: '李明教练',
coachShort: '李明',
location: '一楼 大厅',
footerText: '可取消(截止 07/15 07:00',
canCancel: true
},
{
id: 2,
title: '私教健身课',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
status: 'pending',
statusLabel: '待上课',
schedule: '07月18日 14:00-15:00',
dateDay: '07',
dateMonth: '月18日',
timeRange: '14:00-15:00',
coach: '王强教练',
coachShort: '王强',
location: 'B区私教室',
footerText: '地点:B区私教室',
canCancel: true
}
],
history: [
{
id: 3,
title: '动感单车',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
status: 'completed',
statusLabel: '已完成',
schedule: '07月10日 19:00-20:00',
coach: '赵敏教练',
footerText: '已签到',
canCancel: false
},
{
id: 4,
title: '普拉提进阶',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
status: 'cancelled',
statusLabel: '已取消',
schedule: '07月05日 10:00-11:00',
coach: '李明教练',
footerText: '用户主动取消',
canCancel: false
}
]
}
/** 可预约课程 catalog */
export const courseCatalogMock = {
coaches: ['全部', '李明教练', '王强教练', '赵敏教练'],
periodOptions: [
{ key: 'all', label: '全部时段' },
{ key: 'morning', label: '上午' },
{ key: 'afternoon', label: '下午' },
{ key: 'evening', label: '晚上' }
],
typeOptions: [
{ key: 'all', label: '全部' },
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' }
],
courses: [
{
id: 101,
title: '瑜伽基础班',
type: 'group',
coach: '李明教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png',
date: '2024-07-15',
startTime: '09:00',
endTime: '10:00',
location: '一楼大厅',
enrolled: 12,
capacity: 20,
price: '次卡扣 1 次',
payType: 'session',
period: 'morning',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
intro: '适合零基础学员,重点提升柔韧性与呼吸控制。',
suitable: '久坐办公族、初学者、想改善体态者',
coachBio: '国家一级瑜伽指导员,5年教学经验',
coachRating: 4.9,
reviews: [
{ user: '会员 A', score: 5, text: '教练讲解很细致,氛围很好' },
{ user: '会员 B', score: 5, text: '适合新手,推荐' }
],
cancelRule: '至少提前 2 小时取消,否则视为爽约'
},
{
id: 102,
title: 'HIIT 燃脂团课',
type: 'group',
coach: '赵敏教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png',
date: '2024-07-15',
startTime: '19:00',
endTime: '20:00',
location: '有氧区',
enrolled: 18,
capacity: 20,
price: '时长卡',
payType: 'duration',
period: 'evening',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
intro: '高强度间歇训练,快速燃脂提升心肺。',
suitable: '有一定运动基础、目标减脂者',
coachBio: 'ACE 认证教练,擅长 HIIT 与动感单车',
coachRating: 4.8,
reviews: [{ user: '会员 C', score: 5, text: '强度够,出汗很多' }],
cancelRule: '至少提前 2 小时取消'
},
{
id: 103,
title: '私教 · 力量训练',
type: 'private',
coach: '王强教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png',
date: '2024-07-16',
startTime: '14:00',
endTime: '15:00',
location: 'B区私教室',
enrolled: 1,
capacity: 1,
price: '私教课时卡',
payType: 'private',
period: 'afternoon',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
intro: '一对一力量训练,定制训练计划。',
suitable: '增肌塑形、康复训练',
coachBio: 'NSCA 认证私教,8年从业经验',
coachRating: 5.0,
reviews: [{ user: '会员 D', score: 5, text: '非常专业' }],
cancelRule: '至少提前 2 小时取消'
},
{
id: 104,
title: '普拉提进阶',
type: 'group',
coach: '李明教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png',
date: '2024-07-17',
startTime: '10:30',
endTime: '11:30',
location: '二楼瑜伽室',
enrolled: 8,
capacity: 15,
price: '次卡扣 1 次',
payType: 'session',
period: 'morning',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
intro: '核心稳定与体态矫正进阶课程。',
suitable: '有普拉提基础者',
coachBio: '国家一级瑜伽指导员',
coachRating: 4.9,
reviews: [],
cancelRule: '至少提前 2 小时取消'
},
{
id: 105,
title: '动感单车',
type: 'group',
coach: '赵敏教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png',
date: '2024-07-18',
startTime: '18:30',
endTime: '19:30',
location: '单车房',
enrolled: 20,
capacity: 20,
price: '储值卡 ¥39',
payType: 'stored',
period: 'evening',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
intro: '音乐骑行,团队氛围燃脂。',
suitable: '所有级别,可调节阻力',
coachBio: 'ACE 认证教练',
coachRating: 4.7,
reviews: [{ user: '会员 E', score: 4, text: '音乐很带感' }],
cancelRule: '至少提前 2 小时取消'
}
]
}
/** 个人中心其它模块 mock 数据 */
export const moduleMock = {
trainingReport: {
periodLabel: '本周训练',
summary: {
sessions: 4,
hours: 6.5,
calories: 2180,
streak: 3,
visits: 5
},
monthlyHours: [
{ label: '第1周', value: 4.2 },
{ label: '第2周', value: 5.8 },
{ label: '第3周', value: 6.5 },
{ label: '第4周', value: 5.0 }
],
monthlyCalories: [
{ label: '第1周', value: 1200 },
{ label: '第2周', value: 1680 },
{ label: '第3周', value: 2180 },
{ label: '第4周', value: 1850 }
],
weeklyHours: [
{ label: '一', value: 1.2 },
{ label: '二', value: 0 },
{ label: '三', value: 1.5 },
{ label: '四', value: 0.8 },
{ label: '五', value: 1.0 },
{ label: '六', value: 2.0 },
{ label: '日', value: 0 }
],
sessions: [
{
id: 1,
title: '瑜伽基础班',
coach: '李明教练',
date: '2024-07-12',
time: '09:00-10:00',
duration: '60分钟',
calories: 320,
type: 'group',
typeLabel: '团课'
},
{
id: 2,
title: '自由训练 · 力量',
coach: '自主训练',
date: '2024-07-11',
time: '18:30-19:45',
duration: '75分钟',
calories: 480,
type: 'free',
typeLabel: '自由'
},
{
id: 3,
title: '私教 · 核心塑形',
coach: '王强教练',
date: '2024-07-10',
time: '14:00-15:00',
duration: '60分钟',
calories: 410,
type: 'private',
typeLabel: '私教'
},
{
id: 4,
title: '动感单车',
coach: '赵敏教练',
date: '2024-07-08',
time: '19:00-20:00',
duration: '60分钟',
calories: 520,
type: 'group',
typeLabel: '团课'
}
]
},
couponTabs: [
{ key: 'available', label: '可用' },
{ key: 'used', label: '已使用' },
{ key: 'expired', label: '已过期' }
],
coupons: [
{
id: 1,
status: 'available',
amount: 50,
title: '满500减50',
desc: '全场团课/私教可用',
expire: '2024-12-31',
minSpend: 500,
tag: '通用券',
rules: '1. 满500元可用\n2. 适用于团课/私教\n3. 不可与其他优惠叠加\n4. 有效期至2024-12-31',
scope: '全门店 · 团课/私教',
flow: '选择课程 → 确认订单 → 选择优惠券 → 完成支付'
},
{
id: 2,
status: 'available',
amount: 30,
title: '新人专享',
desc: '首次购课立减',
expire: '2024-08-31',
minSpend: 200,
tag: '新人券',
rules: '1. 限新注册用户首次购课\n2. 满200可用',
scope: '全门店 · 首次购课',
flow: '首次预约课程时自动提示使用'
},
{
id: 3,
status: 'used',
amount: 20,
title: '签到奖励券',
desc: '连续签到7天获得',
expire: '2024-07-01',
minSpend: 100,
tag: '奖励券',
usedAt: '2024-06-28',
rules: '满100可用',
scope: '团课',
flow: '预约时使用'
},
{
id: 4,
status: 'expired',
amount: 100,
title: '周年庆特惠',
desc: '满1000可用',
expire: '2024-06-01',
minSpend: 1000,
tag: '活动券',
rules: '满1000可用,已过期',
scope: '全门店',
flow: '—'
}
],
couponCenter: [
{
id: 11,
amount: 20,
title: '周末团课券',
desc: '周末团课满200减20',
expireDays: 30,
minSpend: 200,
tag: '可领取',
claimed: false
},
{
id: 12,
amount: 50,
title: '私教体验券',
desc: '私教课满500减50',
expireDays: 15,
minSpend: 500,
tag: '限时',
claimed: false
},
{
id: 13,
amount: 10,
title: '签到加油券',
desc: '无门槛10元券',
expireDays: 7,
minSpend: 0,
tag: '每日',
claimed: true
}
],
pointsConfig: {
rate: '100积分 = 1元',
rule: '签到、训练、邀请好友、购课均可获得积分;积分可用于商城兑换。'
},
pointsRewards: [
{ id: 1, name: '团课体验券', cost: 500, stock: 12, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png' },
{ id: 2, name: '运动毛巾', cost: 800, stock: 5, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png' },
{ id: 3, name: '私教体验30分钟', cost: 2000, stock: 3, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/usercheck.png' },
{ id: 4, name: '蛋白粉小样', cost: 350, stock: 20, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/star.png' }
],
pointsHistory: [
{ id: 1, type: 'earn', title: '团课签到', amount: 50, time: '2024-07-12 09:10', balance: 1250 },
{ id: 2, type: 'earn', title: '邀请好友注册', amount: 200, time: '2024-07-08 15:30', balance: 1200 },
{ id: 3, type: 'spend', title: '兑换团课体验券', amount: -500, time: '2024-07-01 11:00', balance: 1000 },
{ id: 4, type: 'earn', title: '会员卡续费奖励', amount: 100, time: '2024-07-01 10:05', balance: 1500 },
{ id: 5, type: 'earn', title: '体测完成奖励', amount: 30, time: '2024-06-28 18:45', balance: 1400 }
],
referralRules: [
'好友通过您的邀请码注册,双方各得 100 积分',
'好友首次购课成功后,您额外获得 300 积分',
'每月邀请奖励上限 10 人,超出不再计奖',
'积分可用于兑换课程体验券及周边礼品'
],
referralRecords: [
{ id: 1, name: '李**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png', status: 'purchased', statusLabel: '已购课', time: '2024-07-05', reward: '+300积分', rewardStatus: '已发放' },
{ id: 2, name: '王**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png', status: 'registered', statusLabel: '已注册', time: '2024-06-20', reward: '+100积分', rewardStatus: '已发放' },
{ id: 3, name: '陈**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png', status: 'invited', statusLabel: '已邀请', time: '2024-06-15', reward: '待注册', rewardStatus: '待发放' },
{ id: 4, name: '赵**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user3.png', status: 'purchased', statusLabel: '已购课', time: '2024-06-01', reward: '+300积分', rewardStatus: '已发放' },
{ id: 5, name: '刘**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png', status: 'registered', statusLabel: '已注册', time: '2024-05-28', reward: '+100积分', rewardStatus: '已发放' }
],
referralRewardSummary: {
totalPoints: 800,
totalCoupons: 2,
pendingCount: 1
},
myCourseTabs: [
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' },
{ key: 'online', label: '线上课' },
{ key: 'package', label: '训练营' }
],
myCourses: {
group: {
ongoing: [
{
id: 1,
title: '瑜伽基础班',
coach: '李明教练',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
progress: 6,
total: 12,
schedule: '每周二、四 09:00',
location: '一楼大厅',
nextClass: '07月16日 09:00',
canCancel: true,
bookingId: 1
}
],
completed: [
{
id: 3,
title: '动感单车入门',
coach: '赵敏教练',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
progress: 8,
total: 8,
schedule: '已结课',
location: '单车房',
completedAt: '2024-06-30',
canEvaluate: true
}
]
},
private: {
remaining: 7,
coach: '王强教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png',
nextClass: '07月15日 14:00',
bookings: [
{ id: 2, title: '私教 · 力量训练', time: '07月18日 14:00', status: '已预约', location: 'B区私教室' }
],
completed: [
{ id: 5, title: '私教 · 核心塑形', time: '2024-07-10 14:00', coach: '王强教练' }
]
},
online: [
{
id: 201,
title: '居家核心训练',
cover: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
duration: '45分钟',
progress: 60,
chapters: 6,
watched: 4,
type: 'video'
},
{
id: 202,
title: '直播 · 晨间拉伸',
cover: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
duration: '30分钟',
progress: 0,
liveTime: '07月20日 07:00',
type: 'live'
}
],
package: [
{
id: 301,
title: '28天减脂训练营',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
progress: 3,
total: 10,
coach: '李明教练',
schedule: '每周5练'
}
]
},
checkInTabs: [
{ key: 'all', label: '全部' },
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' },
{ key: 'free', label: '自由' }
],
checkInHistory: [
{
id: 1,
title: '今日签到 · 瑜伽初级班',
time: '2024-07-12 09:05',
tag: '团课',
tagTheme: 'group',
location: '一楼大厅'
},
{
id: 2,
title: '自由训练 · 进馆记录',
time: '2024-07-11 18:30',
tag: '自由',
tagTheme: 'free',
location: '器械区'
},
{
id: 3,
title: '私教课 · 力量训练',
time: '2024-07-10 14:00',
tag: '私教',
tagTheme: 'private',
location: 'B区私教室'
},
{
id: 4,
title: '团课签到 · 动感单车',
time: '2024-07-08 19:02',
tag: '团课',
tagTheme: 'group',
location: '单车房'
},
{
id: 5,
title: '自由训练 · 进馆记录',
time: '2024-07-06 17:45',
tag: '自由',
tagTheme: 'free',
location: '有氧区'
}
]
}
@@ -1,270 +0,0 @@
import { moduleMock } from './mockData.js'
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
export function getDefaultModuleState() {
return {
trainingReport: clone(moduleMock.trainingReport),
coupons: clone(moduleMock.coupons),
couponCenter: clone(moduleMock.couponCenter),
pointsHistory: clone(moduleMock.pointsHistory),
pointsRewards: clone(moduleMock.pointsRewards),
redeemRecords: [],
referralRecords: clone(moduleMock.referralRecords),
myCourses: clone(moduleMock.myCourses),
checkInHistory: clone(moduleMock.checkInHistory)
}
}
export function mergeModuleState(saved) {
const defaults = getDefaultModuleState()
if (!saved) return defaults
return {
trainingReport: { ...defaults.trainingReport, ...(saved.trainingReport || {}) },
coupons: saved.coupons?.length ? saved.coupons : defaults.coupons,
couponCenter: saved.couponCenter?.length ? saved.couponCenter : defaults.couponCenter,
pointsHistory: saved.pointsHistory?.length ? saved.pointsHistory : defaults.pointsHistory,
pointsRewards: saved.pointsRewards?.length ? saved.pointsRewards : defaults.pointsRewards,
redeemRecords: saved.redeemRecords || defaults.redeemRecords,
referralRecords: saved.referralRecords?.length ? saved.referralRecords : defaults.referralRecords,
myCourses: saved.myCourses ? mergeMyCourses(defaults.myCourses, saved.myCourses) : defaults.myCourses,
checkInHistory: saved.checkInHistory?.length ? saved.checkInHistory : defaults.checkInHistory
}
}
function mergeMyCourses(defaults, saved) {
return {
group: saved.group || defaults.group,
private: saved.private || defaults.private,
online: saved.online?.length ? saved.online : defaults.online,
package: saved.package?.length ? saved.package : defaults.package
}
}
function syncCouponSummary(store) {
const available = store.modules.coupons.filter((c) => c.status === 'available')
const top = available[0]
store.couponPoints = {
...store.couponPoints,
amount: top ? `¥${top.amount}` : '暂无',
couponDesc: top
? `${top.minSpend}可用 · ${available.length}`
: '暂无可用优惠券',
couponAction: available.length ? '去使用' : '去领取',
points: store.stats.pointsBalance,
pointsLabel: '我的积分',
pointsAction: '去兑换'
}
return store
}
export function finalizeModules(store) {
syncCouponSummary(store)
store.checkIns = store.modules.checkInHistory.slice(0, 3).map((item) => ({ ...item }))
return store
}
export function getTrainingReportData(store, period = 'week') {
const report = store.modules.trainingReport
const trend = period === 'month' ? report.monthlyHours : report.weeklyHours
const calTrend = period === 'month' ? report.monthlyCalories : report.weeklyHours.map((w, i) => ({
label: w.label,
value: Math.round((report.summary.calories / 7) * (w.value || 0.5))
}))
return {
...report,
period,
summary: {
...report.summary,
hours: store.stats.trainingHours ?? report.summary.hours,
visits: report.summary.visits ?? store.stats.checkInCount ?? 5
},
trendHours: trend.map((t) => ({ ...t, id: t.label })),
trendCalories: calTrend.map((t) => ({ ...t, id: t.label })),
sessions: report.sessions.map((s) => ({ ...s }))
}
}
export function getTrainingSessionById(store, id) {
const session = store.modules.trainingReport.sessions.find((s) => s.id === Number(id))
if (!session) return null
return {
...session,
heartRate: '128 bpm',
comment: '动作标准,核心发力良好,下次可增加负重。',
checkInTime: `${session.date} ${session.time.split('-')[0]}`
}
}
export function filterTrainingSessions(store, filters = {}) {
let list = store.modules.trainingReport.sessions.map((s) => ({ ...s }))
if (filters.type && filters.type !== 'all') {
list = list.filter((s) => s.type === filters.type)
}
return list
}
export function getCouponsByStatus(store, status) {
return store.modules.coupons.filter((c) => c.status === status).map((c) => ({ ...c }))
}
export function getCouponById(store, id) {
const c = store.modules.coupons.find((item) => item.id === Number(id))
return c ? { ...c } : null
}
export function useCoupon(store, id) {
const coupon = store.modules.coupons.find((c) => c.id === id)
if (!coupon || coupon.status !== 'available') return null
coupon.status = 'used'
coupon.usedAt = new Date().toISOString().slice(0, 10)
syncCouponSummary(store)
return coupon
}
export function deleteExpiredCoupon(store, id) {
const idx = store.modules.coupons.findIndex((c) => c.id === id && c.status === 'expired')
if (idx >= 0) store.modules.coupons.splice(idx, 1)
syncCouponSummary(store)
return store
}
export function getCouponCenterList(store) {
return store.modules.couponCenter.map((c) => ({ ...c }))
@@ -1,352 +0,0 @@
import {
getDefaultBodyTestState,
mergeBodyTestState,
getLatestBodyTestRecord,
buildBodyReportSummary
} from './bodyTestStore.js'
import {
getDefaultModuleState,
mergeModuleState,
finalizeModules
} from './moduleStore.js'
import { getDefaultCourseCatalog,
mergeCourseCatalog,
canCancelBooking
} from './bookingStore.js'
import { getMemberId as requestGetMemberId } from '@/utils/request.js'
const STORAGE_KEY = 'gym_member_info_v1'
/**
* 获取当前登录会员ID
* @deprecated 请使用 @/utils/request.js 中的 getMemberId
*/
export function getCurrentMemberId() {
return requestGetMemberId()
}
/**
* 获取登录信息
*/
export function getLoginMemberInfo() {
return uni.getStorageSync('loginMemberInfo') || null
}
/**
* 获取token
*/
export function getToken() {
return uni.getStorageSync('token') || null
}
export function buildCardTip(remainingDays) {
return `距离下次到期还有${remainingDays}天,请及时续费`
}
function applyCardInfo(store) {
const days = computeRemainingDays(store.card.validityEnd)
store.card.remainingDays = days
store.cardInfo.remainingDays = days
store.cardInfo.tip = buildCardTip(days)
return store
}
export function syncStats(store) {
store.stats = {
...store.stats,
checkInCount: store.stats.checkInCount ?? 0,
courseCount: store.stats.courseCount ?? 0,
pointsBalance: store.stats.pointsBalance ?? 0
}
return store
}
function finalizeStore(store) {
syncStats(store)
applyCardInfo(store)
const latestBodyTest = getLatestBodyTestRecord(store)
if (latestBodyTest) {
const previous = store.bodyTest.records[1]
store.bodyReport = buildBodyReportSummary(latestBodyTest, previous)
}
if (store.modules) {
finalizeModules(store)
}
return store
}
function getDefaultStore() {
return finalizeStore({
stats: {},
cardInfo: {},
card: {},
cards: [],
records: [],
ongoingBookings: [],
historyBookings: [],
checkIns: [],
bodyReport: {},
bodyTest: getDefaultBodyTestState(),
modules: getDefaultModuleState(),
courseCatalog: getDefaultCourseCatalog(),
couponPoints: {},
referral: {},
storedCard: {
balance: 0,
zeroVerified: false
}
})
}
export { getDefaultStore }
function mergeDefaults(saved) {
const defaults = getDefaultStore()
return finalizeStore({
stats: { ...defaults.stats, ...(saved.stats || {}) },
cardInfo: { ...defaults.cardInfo, ...(saved.cardInfo || {}) },
card: { ...defaults.card, ...(saved.card || {}) },
cards: saved.cards?.length ? saved.cards : defaults.cards,
records: saved.records?.length ? saved.records : defaults.records,
ongoingBookings: saved.ongoingBookings ?? defaults.ongoingBookings,
historyBookings: saved.historyBookings ?? defaults.historyBookings,
checkIns: saved.checkIns?.length ? saved.checkIns : defaults.checkIns,
bodyReport: { ...defaults.bodyReport, ...(saved.bodyReport || {}) },
bodyTest: mergeBodyTestState(saved.bodyTest),
modules: mergeModuleState(saved.modules),
courseCatalog: mergeCourseCatalog(saved.courseCatalog),
couponPoints: { ...defaults.couponPoints, ...(saved.couponPoints || {}) },
referral: { ...defaults.referral, ...(saved.referral || {}) },
storedCard: { ...defaults.storedCard, ...(saved.storedCard || {}) }
})
}
export function loadMemberStore() {
try {
const saved = uni.getStorageSync(STORAGE_KEY)
if (saved && typeof saved === 'object') {
return mergeDefaults(saved)
}
} catch (e) {
console.warn('[memberStore] load failed', e)
}
return getDefaultStore()
}
export function saveMemberStore(store) {
uni.setStorageSync(STORAGE_KEY, store)
}
/** 解析为本地 0 点,避免 ISO 字符串时区偏差 */
export function parseLocalDate(dateStr) {
if (!dateStr) return null
const str = String(dateStr).trim()
const iso = str.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (iso) {
return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]))
}
const cn = str.match(/(\d{4})年(\d{2})月(\d{2})日/)
if (cn) {
return new Date(Number(cn[1]), Number(cn[2]) - 1, Number(cn[3]))
}
const parsed = new Date(str.replace(/-/g, '/'))
return Number.isNaN(parsed.getTime()) ? null : parsed
}
function formatIsoDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function formatChineseDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${y}${m}${day}`
}
function formatRecordTime(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}`
}
function nextRecordId(records) {
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
}
export function computeRemainingDays(endDateStr) {
const end = parseLocalDate(endDateStr)
if (!end) return 0
const now = new Date()
now.setHours(0, 0, 0, 0)
end.setHours(0, 0, 0, 0)
const diff = Math.ceil((end - now) / 86400000)
return Math.max(0, diff)
}
export function formatUpcomingAlert(booking) {
if (!booking) return ''
const timePart = booking.timeRange || booking.schedule?.split(' ')[1] || ''
return `明天 ${timePart} 有一堂${booking.title},请提前 30 分钟到场`
}
export function toBookingPreviewItem(item) {
return {
id: item.id,
dateDay: item.dateDay,
dateMonth: item.dateMonth,
desc: `${item.title} · ${item.timeRange}`,
coach: item.coachShort || item.coach.replace('教练', ''),
location: item.location ? `地点:${item.location}` : '',
status: item.status,
statusLabel: item.statusLabel
}
}
export function getBookingPreview(store, limit = 2) {
return store.ongoingBookings.slice(0, limit).map(toBookingPreviewItem)
}
export function getExpiringCards(store, daysThreshold = 30) {
if (!store.cards || store.cards.length === 0) {
return []
}
return store.cards.filter(card => {
const remainingDays = computeRemainingDays(card.validityEnd || card.expireTime)
return remainingDays > 0 && remainingDays <= daysThreshold
}).sort((a, b) => {
const daysA = computeRemainingDays(a.validityEnd || a.expireTime)
const daysB = computeRemainingDays(b.validityEnd || b.expireTime)
return daysA - daysB
})
}
export function getCenterPageData(store) {
const expiringCards = getExpiringCards(store, 30)
return {
stats: {
checkInCount: store.stats?.checkInCount ?? 0,
courseCount: store.stats?.courseCount ?? 0,
pointsBalance: store.stats?.pointsBalance ?? 0
},
cardInfo: { ...store.cardInfo },
expiringCards: expiringCards,
bookingPreview: getBookingPreview(store),
checkIns: store.checkIns.map((item) => ({ ...item })),
bodyReport: { ...store.bodyReport },
couponPoints: {
...store.couponPoints,
points: store.stats.pointsBalance
},
referral: { ...store.referral }
}
}
export function cancelOngoingBooking(store, id) {
const index = store.ongoingBookings.findIndex((b) => b.id === id)
if (index < 0) return { ok: false, message: '预约不存在' }
const item = store.ongoingBookings[index]
if (!canCancelBooking(item)) {
return { ok: false, message: '距开课不足2小时,无法取消' }
}
const [removed] = store.ongoingBookings.splice(index, 1)
if (removed.courseId) {
const course = store.courseCatalog.find((c) => c.id === removed.courseId)
if (course && course.enrolled > 0) course.enrolled -= 1
}
// 同步从 myCourses.group.ongoing 中移除
if (store.modules?.myCourses?.group?.ongoing) {
const myCoursesIndex = store.modules.myCourses.group.ongoing.findIndex((c) => c.id === id)
if (myCoursesIndex >= 0) {
store.modules.myCourses.group.ongoing.splice(myCoursesIndex, 1)
}
}
store.historyBookings.unshift({
...removed,
status: 'cancelled',
statusLabel: '已取消',
footerText: '用户主动取消',
canCancel: false
})
finalizeStore(store)
saveMemberStore(store)
return { ok: true, message: '已取消' }
}
export function renewMemberCard(store, addDays = 90) {
const now = new Date()
now.setHours(0, 0, 0, 0)
let base = parseLocalDate(store.card.validityEnd) || new Date(now)
base.setHours(0, 0, 0, 0)
// 已过期:从今天起续费;未过期:从当前到期日起顺延
if (base < now) {
base = new Date(now)
}
const end = new Date(base)
end.setDate(end.getDate() + addDays)
const validityEnd = formatIsoDate(end)
const validityEndCn = formatChineseDate(end)
store.card.validityEnd = validityEnd
store.card.validity = store.card.validityStart
? `${store.card.validityStart} - ${validityEndCn}`
: `2024年01月01日 - ${validityEndCn}`
store.cardInfo.expireDate = `有效期至 ${validityEndCn}`
store.records.unshift({
id: nextRecordId(store.records),
type: 'consume',
title: '会员卡续费',
time: formatRecordTime(new Date()),
value: `+${addDays}`,
valueType: 'positive',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/pluscircle.png',
iconTheme: 'orange'
})
finalizeStore(store)
saveMemberStore(store)
return store
}
export function saveUserProfile(store, profile) {
// 不再保存profile到store,用户信息从loginMemberInfo获取
if (profile.weight) {
store.bodyReport.weight = profile.weight
}
finalizeStore(store)
saveMemberStore(store)
return store
}
export function persistMemberStore(store) {
finalizeStore(store)
saveMemberStore(store)
return store
}
export function getStoredCardBalance() {
console.log('[storedCard] 余额不使用缓存,每次从后端读取')
return null
}
export function saveStoredCardBalance(balance) {
console.log('[storedCard] 余额不使用缓存,跳过保存')
return Number(balance) || 0
}
export function clearStoredCardCache() {
console.log('[storedCard] 余额不使用缓存,无需清除')
}

Some files were not shown because too many files have changed in this diff Show More