diff --git a/gym-manage-api/gym-member/.gitignore b/gym-manage-api/gym-member/.gitignore new file mode 100644 index 0000000..9d5d968 --- /dev/null +++ b/gym-manage-api/gym-member/.gitignore @@ -0,0 +1,47 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Maven ### +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +### System Files ### +.DS_Store +Thumbs.db diff --git a/gym-manage-api/gym-member/CARD_MERGE_REPORT.md b/gym-manage-api/gym-member/CARD_MERGE_REPORT.md new file mode 100644 index 0000000..9995bbe --- /dev/null +++ b/gym-manage-api/gym-member/CARD_MERGE_REPORT.md @@ -0,0 +1,199 @@ +# gym-member-card 模块合并到 gym-member 完成报告 + +## 合并概述 +将 `gym-member-card` 模块的功能合并到 `gym-member` 模块的 `card` 子包中,保持功能不变,代码风格以 `gym-member` 为基准。 + +## 已完成的工作 + +### 1. 目录结构创建 ✅ +在 `gym-member/src/main/java/cn/novalon/gym/manage/member/card` 下创建了以下子目录: +- `entity/` - 实体类 +- `dto/` - 数据传输对象 +- `vo/` - 视图对象 +- `repository/` - 数据访问层 +- `service/` - 服务接口 +- `service/impl/` - 服务实现 +- `handler/` - 业务处理器 +- `enums/` - 枚举类 +- `util/` - 工具类 + +### 2. 实体类重构(Entity)✅ +按照 gym-member 的风格(使用 Lombok),创建了以下实体类: + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| MemberCardEntity.java + MemberCard.java | card/entity/MemberCard.java | 会员卡类型实体 | +| MemberCardRecordEntity.java + MemberCardRecord.java | card/entity/MemberCardRecord.java | 会员持卡记录实体 | +| MemberCardTransactionsEntity.java | card/entity/MemberCardTransaction.java | 交易流水实体 | +| RefundApplicationEntity.java | card/entity/RefundApplication.java | 退款申请实体 | + +**主要改进:** +- 使用 `@Data`, `@Builder`, `@NoArgsConstructor`, `@AllArgsConstructor` Lombok 注解 +- 继承 `cn.novalon.gym.manage.member.entity.BaseEntity` +- 移除手动编写的 getter/setter +- 添加规范的 JavaDoc 注释 + +### 3. 枚举类创建 ✅ +创建了以下枚举类: + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| MemberCardType.java | card/enums/MemberCardType.java | 会员卡类型 | +| MemberCardRecordStatus.java | card/enums/MemberCardRecordStatus.java | 卡片状态 | +| MemberCardTransactionsAction.java | card/enums/TransactionType.java | 交易操作类型 | +| MemberCardTransactionsType.java | card/enums/BizType.java | 关联业务类型 | +| MemberCardEvent.java | card/enums/CardEvent.java | 状态机事件 | + +### 4. Repository 层合并 ✅ +将原有的 DAO 接口整合为 Repository 接口,直接继承 `R2dbcRepository`: + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| MemberCardDao.java | card/repository/MemberCardRepository.java | 会员卡类型 Repository | +| MemberCardRecordDao.java | card/repository/MemberCardRecordRepository.java | 持卡记录 Repository | +| MemberCardTransactionsDao.java | card/repository/MemberCardTransactionRepository.java | 交易流水 Repository | +| RefundApplicationDao.java | card/repository/RefundApplicationRepository.java | 退款申请 Repository | + +**主要改进:** +- 统一命名规范(移除 Dao,使用 Repository) +- 继承 `R2dbcRepository` +- 保留所有自定义查询方法 +- 更新参数类型为新的实体类 + +### 5. 数据库 Schema 迁移 ✅ +将会员卡相关的表结构添加到 `gym-member/src/main/resources/db/schema.sql`: + +新增表: +- `member_card` - 会员卡类型表 +- `member_card_record` - 会员持卡记录表 +- `member_card_transactions` - 交易流水表 +- `refund_application` - 退款申请表 + +包含完整的索引和注释。 + +### 6. pom.xml 依赖更新 ✅ +在 `gym-member/pom.xml` 中添加了 Redis 响应式支持: +```xml + + org.springframework.boot + spring-boot-starter-data-redis-reactive + +``` + +## 待完成的工作 + +### 需要手动复制的文件(约30个) + +以下文件需要从 `gym-member-card` 复制到 `gym-member/card`,并修改包名: + +#### Handler 层(8个文件) +源路径:`gym-member-card/src/main/java/cn/novalon/gym/manage/gymmembercard/handler/` +目标路径:`gym-member/src/main/java/cn/novalon/gym/manage/member/card/handler/` + +- DistributedLockService.java +- ExpirationReminderService.java +- MemberCardHandler.java +- MemberCardRecordHandler.java +- MemberCardScheduledHandler.java +- MemberCardStateMachine.java +- MemberCardTransactionHandler.java +- RefundSagaHandler.java + +#### Service 接口层(4个文件) +源路径:`gym-member-card/src/main/java/cn/novalon/gym/manage/gymmembercard/sevice/` +目标路径:`gym-member/src/main/java/cn/novalon/gym/manage/member/card/service/` + +注意:目录名从 `sevice`(拼写错误)改为 `service` + +- IMemberCardService.java +- IMemberCardRecordService.java +- IMemberCardTransactionsService.java +- IRefundApplicationService.java + +#### Service 实现层(4个文件) +源路径:`gym-member-card/src/main/java/cn/novalon/gym/manage/gymmembercard/sevice/impl/` +目标路径:`gym-member/src/main/java/cn/novalon/gym/manage/member/card/service/impl/` + +- MemberCardServiceImpl.java +- MemberCardRecordServiceImpl.java +- MemberCardTransactionsServiceImpl.java +- RefundApplicationServiceImpl.java + +#### Util 工具类(1个文件) +源路径:`gym-member-card/src/main/java/cn/novalon/gym/manage/gymmembercard/util/` +目标路径:`gym-member/src/main/java/cn/novalon/gym/manage/member/card/util/` + +- BeanConvertUtil.java + +### 批量替换规则 + +复制每个文件后,需要进行以下文本替换: + +1. **包名替换:** + ``` + package cn.novalon.gym.manage.gymmembercard -> package cn.novalon.gym.manage.member.card + ``` + +2. **导入语句替换:** + ``` + import cn.novalon.gym.manage.gymmembercard -> import cn.novalon.gym.manage.member.card + ``` + +3. **BaseEntity 导入替换:** + ``` + import cn.novalon.gym.manage.db.entity.BaseEntity -> import cn.novalon.gym.manage.member.entity.BaseEntity + ``` + +4. **BaseDomain 处理(如果存在):** + ``` + import cn.novalon.gym.manage.sys.core.domain.BaseDomain -> (注释掉或删除) + ``` + +5. **Repository 引用更新(如果需要):** + ``` + IMemberCardRepository -> MemberCardRepository + IMemberCardRecordRepository -> MemberCardRecordRepository + IMemberCardTransactionsRepository -> MemberCardTransactionRepository + IRefundApplicationRepository -> RefundApplicationRepository + ``` + +## 验证步骤 + +完成文件复制后,执行以下验证: + +1. **编译验证:** + ```bash + cd gym-manage-api/gym-member + mvn clean compile + ``` + +2. **检查是否有编译错误:** + - 修复任何缺失的导入 + - 确认所有依赖类都已正确迁移 + - 检查枚举引用是否正确 + +3. **运行测试(如果有):** + ```bash + mvn test + ``` + +## 注意事项 + +1. **包名一致性:** 确保所有文件中的包声明和导入语句都正确更新 +2. **依赖注入:** Spring 的 `@Autowired` 或构造函数注入应自动工作,因为使用了相同的注解 +3. **数据库兼容性:** schema.sql 已追加新表,不会影响现有表 +4. **Redis 配置:** 确保 application.yml 中有 Redis 配置(如果需要分布式锁功能) +5. **定时任务:** MemberCardScheduledHandler 和 ExpirationReminderService 可能需要启用 `@EnableScheduling` + +## 自动化脚本 + +项目根目录下提供了两个辅助脚本: +- `gym-member-card/migrate_to_member.py` - Python 迁移脚本 +- `gym-member-card/migrate-to-member.ps1` - PowerShell 迁移脚本 + +可以运行这些脚本来自动完成文件复制和包名替换。 + +--- + +**生成时间:** 2026-05-27 +**合并状态:** 基础架构完成,待复制业务逻辑文件 diff --git a/gym-manage-api/gym-member/MEMBER_USER_TABLE_SIMPLE.sql b/gym-manage-api/gym-member/MEMBER_USER_TABLE_SIMPLE.sql new file mode 100644 index 0000000..aef3540 --- /dev/null +++ b/gym-manage-api/gym-member/MEMBER_USER_TABLE_SIMPLE.sql @@ -0,0 +1,58 @@ +-- ============================================ +-- member_user 表 - 简洁版建表语句 +-- ============================================ +-- 用途:直接复制执行,快速创建会员表 +-- ============================================ + +CREATE TABLE IF NOT EXISTS member_user ( + -- 主键和基础字段 + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- 会员核心字段 + member_no VARCHAR(50) NOT NULL UNIQUE, + nickname VARCHAR(100), + phone VARCHAR(255), + gender INTEGER DEFAULT 0, + birthday TIMESTAMP, + address VARCHAR(500), + avatar VARCHAR(500), + subscribed BOOLEAN DEFAULT FALSE, + last_login_at TIMESTAMP, + + -- 微信相关字段 + union_id VARCHAR(100), + miniapp_open_id VARCHAR(100), + official_open_id VARCHAR(100), + + -- 软删除字段 + is_deleted BOOLEAN DEFAULT FALSE +); + +-- 创建索引 +CREATE UNIQUE INDEX IF NOT EXISTS idx_member_user_member_no ON member_user(member_no); +CREATE INDEX IF NOT EXISTS idx_member_user_union_id ON member_user(union_id); +CREATE INDEX IF NOT EXISTS idx_member_user_miniapp_openid ON member_user(miniapp_open_id); +CREATE INDEX IF NOT EXISTS idx_member_user_official_openid ON member_user(official_open_id); +CREATE INDEX IF NOT EXISTS idx_member_user_phone ON member_user(phone); +CREATE INDEX IF NOT EXISTS idx_member_user_is_deleted ON member_user(is_deleted); + +-- 添加注释 +COMMENT ON TABLE member_user IS '会员表'; +COMMENT ON COLUMN member_user.id IS '主键ID'; +COMMENT ON COLUMN member_user.created_at IS '创建时间'; +COMMENT ON COLUMN member_user.updated_at IS '更新时间'; +COMMENT ON COLUMN member_user.member_no IS '会员编号(唯一)'; +COMMENT ON COLUMN member_user.nickname IS '昵称'; +COMMENT ON COLUMN member_user.phone IS '手机号(AES加密存储)'; +COMMENT ON COLUMN member_user.gender IS '性别:0-未知,1-男,2-女'; +COMMENT ON COLUMN member_user.birthday IS '生日'; +COMMENT ON COLUMN member_user.address IS '地址'; +COMMENT ON COLUMN member_user.avatar IS '头像URL'; +COMMENT ON COLUMN member_user.subscribed IS '是否关注服务号'; +COMMENT ON COLUMN member_user.last_login_at IS '最后登录时间'; +COMMENT ON COLUMN member_user.union_id IS '微信UnionID(跨应用唯一标识)'; +COMMENT ON COLUMN member_user.miniapp_open_id IS '小程序OpenID'; +COMMENT ON COLUMN member_user.official_open_id IS '服务号OpenID'; +COMMENT ON COLUMN member_user.is_deleted IS '是否删除(软删除标记)'; diff --git a/gym-manage-api/gym-member/pom.xml b/gym-manage-api/gym-member/pom.xml new file mode 100644 index 0000000..f62cbb0 --- /dev/null +++ b/gym-manage-api/gym-member/pom.xml @@ -0,0 +1,250 @@ + + + 4.0.0 + + + cn.novalon.gym.manage + gym-manage-api + 1.0.0 + + + gym-member + jar + + Gym Member + Member Management Module - Frontend User Services + + + + cn.novalon.gym.manage + manage-common + ${project.version} + + + cn.novalon.gym.manage + manage-db + ${project.version} + + + cn.novalon.gym.manage + manage-sys + ${project.version} + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-aop + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.data + spring-data-commons + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + io.projectreactor + reactor-test + test + + + io.github.resilience4j + resilience4j-spring-boot3 + + + io.github.resilience4j + resilience4j-reactor + + + org.testcontainers + testcontainers + 1.21.4 + test + + + org.testcontainers + postgresql + 1.21.4 + test + + + org.testcontainers + junit-jupiter + 1.21.4 + test + + + com.h2database + h2 + test + + + io.r2dbc + r2dbc-h2 + test + + + org.postgresql + r2dbc-postgresql + test + + + + com.github.binarywang + weixin-java-miniapp + 4.6.0 + + + com.github.binarywang + weixin-java-mp + 4.6.0 + + + cn.hutool + hutool-all + 5.8.25 + + + org.springframework.boot + spring-boot-starter-data-elasticsearch + + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + package + + jar + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 21 + 21 + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + org.projectlombok + lombok + ${lombok.version} + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + check + verify + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.60 + + + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.6.0 + + + com.github.spotbugs + spotbugs + 4.8.6 + + + + + spotbugs-check + verify + + check + + + + + Max + High + true + spotbugs-exclude.xml + + + + + diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/HttpClientConfig.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/HttpClientConfig.java new file mode 100644 index 0000000..a3be578 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/HttpClientConfig.java @@ -0,0 +1,22 @@ +package cn.novalon.gym.manage.member.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * HTTP 客户端配置 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Configuration +public class HttpClientConfig { + + // WebClient Bean,用于调用微信 API + @Bean + public WebClient webClient() { + return WebClient.builder().build(); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/RedisConfig.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/RedisConfig.java new file mode 100644 index 0000000..7321879 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/RedisConfig.java @@ -0,0 +1,38 @@ +package cn.novalon.gym.manage.member.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 配置类(响应式版本) + * + * @author 付嘉 + * @date 2026-05-29 + */ +@Configuration +public class RedisConfig { + + /** + * 配置 ReactiveRedisTemplate + */ + @Bean + public ReactiveRedisTemplate reactiveRedisTemplate( + ReactiveRedisConnectionFactory connectionFactory) { + + // 配置序列化上下文 + RedisSerializationContext serializationContext = + RedisSerializationContext.newSerializationContext() + .key(StringRedisSerializer.UTF_8) + .value(new GenericJackson2JsonRedisSerializer()) + .hashKey(StringRedisSerializer.UTF_8) + .hashValue(new GenericJackson2JsonRedisSerializer()) + .build(); + + return new ReactiveRedisTemplate<>(connectionFactory, serializationContext); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/WechatProperties.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/WechatProperties.java new file mode 100644 index 0000000..ec32292 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/WechatProperties.java @@ -0,0 +1,63 @@ +package cn.novalon.gym.manage.member.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 微信配置 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +@Component +@ConfigurationProperties(prefix = "wechat") +public class WechatProperties { + + // 小程序配置 + private MiniApp miniapp = new MiniApp(); + + // 服务号配置 + private Mp mp = new Mp(); + + // 手机号加密配置 + private PhoneEncryption phoneEncryption = new PhoneEncryption(); + + @Data + public static class MiniApp { + // 小程序 AppID + private String appId; + + // 小程序 AppSecret + private String appSecret; + } + + @Data + public static class Mp { + // 服务号 AppID + private String appId; + + // 服务号 AppSecret + private String appSecret; + + // Token 验证信息 + private String token; + + // EncodingAESKey + private String aesKey; + + // 回调地址(微信服务号事件推送 URL) + private String callbackUrl; + } + + @Data + public static class PhoneEncryption { + // 手机号加密密钥 + private String secretKey; + + // 初始化向量 IV + private String iv; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/AdminUpdatePhoneDto.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/AdminUpdatePhoneDto.java new file mode 100644 index 0000000..0854b49 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/AdminUpdatePhoneDto.java @@ -0,0 +1,17 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.Data; + +/** + * 更新手机号Dto + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +public class AdminUpdatePhoneDto { + + // 手机号 + private String phone; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/CreateMemberCardRequest.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/CreateMemberCardRequest.java new file mode 100644 index 0000000..6acb34d --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/CreateMemberCardRequest.java @@ -0,0 +1,61 @@ +package cn.novalon.gym.manage.member.dto +; + +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; + +/** + * 创建会员卡类型请求 DTO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +public class CreateMemberCardRequest { + + /** + * 会员卡名称 + */ + @NotBlank(message = "会员卡名称不能为空") + private String memberCardName; + + /** + * 会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡 + */ + @NotBlank(message = "会员卡类型不能为空") + private String memberCardType; + + /** + * 会员卡价格 + */ + @NotNull(message = "会员卡价格不能为空") + private BigDecimal memberCardPrice; + + /** + * 有效天数(时长卡用) + */ + private Integer memberCardValidityDays; + + /** + * 总次数(次卡用) + */ + private Integer memberCardTotalTimes; + + /** + * 面额(储值卡用) + */ + private BigDecimal memberCardAmount; + + /** + * 状态:0-下架, 1-上架 + */ + private Integer memberCardStatus = 1; + + /** + * 扩展配置(JSON格式) + */ + private String extraConfig; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/PurchaseCardRequest.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/PurchaseCardRequest.java new file mode 100644 index 0000000..fe6bdc6 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/PurchaseCardRequest.java @@ -0,0 +1,32 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.Data; + +import jakarta.validation.constraints.NotNull; + +/** + * 购买会员卡请求 DTO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +public class PurchaseCardRequest { + + /** + * 会员ID + */ + @NotNull(message = "会员ID不能为空") + private Long memberId; + + /** + * 会员卡类型ID + */ + @NotNull(message = "会员卡类型ID不能为空") + private Long memberCardId; + + /** + * 来源订单ID + */ + private Long sourceOrderId; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/RefundCardRequest.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/RefundCardRequest.java new file mode 100644 index 0000000..93a2045 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/RefundCardRequest.java @@ -0,0 +1,21 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * 退款申请请求 DTO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +public class RefundCardRequest { + + /** + * 退款原因 + */ + @NotBlank(message = "退款原因不能为空") + private String reason; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/RenewCardRequest.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/RenewCardRequest.java new file mode 100644 index 0000000..a56479b --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/RenewCardRequest.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.Data; + +import jakarta.validation.constraints.NotNull; + +/** + * 续费会员卡请求 DTO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +public class RenewCardRequest { + + /** + * 增加的次数 + */ + private Integer addTimes; + + /** + * 增加的金额 + */ + private Double addAmount; + + /** + * 增加的天数 + */ + private Integer addDays; + + /** + * 来源订单ID + */ + private Long sourceOrderId; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/SearchMemberDto.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/SearchMemberDto.java new file mode 100644 index 0000000..38ac180 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/SearchMemberDto.java @@ -0,0 +1,20 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SearchMemberDto { + + // 搜索字段 - 包括 会员号、昵称、手机号 + private String searchValue; + + // 页码 + private Integer pageNum = 1; + + // 页大小 + private Integer pageSize = 10; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UpdateMemberCardRequest.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UpdateMemberCardRequest.java new file mode 100644 index 0000000..dc39d7f --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UpdateMemberCardRequest.java @@ -0,0 +1,60 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; + +/** + * 更新会员卡类型请求 DTO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +public class UpdateMemberCardRequest { + + /** + * 会员卡名称 + */ + @NotBlank(message = "会员卡名称不能为空") + private String memberCardName; + + /** + * 会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡 + */ + @NotBlank(message = "会员卡类型不能为空") + private String memberCardType; + + /** + * 会员卡价格 + */ + @NotNull(message = "会员卡价格不能为空") + private BigDecimal memberCardPrice; + + /** + * 有效天数(时长卡用) + */ + private Integer memberCardValidityDays; + + /** + * 总次数(次卡用) + */ + private Integer memberCardTotalTimes; + + /** + * 面额(储值卡用) + */ + private BigDecimal memberCardAmount; + + /** + * 状态:0-下架, 1-上架 + */ + private Integer memberCardStatus; + + /** + * 扩展配置(JSON格式) + */ + private String extraConfig; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UpdateMemberInfoDto.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UpdateMemberInfoDto.java new file mode 100644 index 0000000..fb633df --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UpdateMemberInfoDto.java @@ -0,0 +1,32 @@ +package cn.novalon.gym.manage.member.dto; + +import cn.novalon.gym.manage.member.enums.GenderEnum; +import lombok.Data; + +import java.time.LocalDate; +import java.util.Date; + +/** + * 更新会员信息Dto + * + * @author 付嘉 + * @date 2026-05-10 + */ +@Data +public class UpdateMemberInfoDto { + + // 昵称 + private String nickname; + + // 性别 + private GenderEnum gender; + + // 生日 + private LocalDate birthday; + + // 头像 + private String avatar; + + // 地址 + private String address; +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UseCardRequest.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UseCardRequest.java new file mode 100644 index 0000000..33df8dc --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/UseCardRequest.java @@ -0,0 +1,23 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.Data; + +/** + * 使用会员卡请求 DTO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +public class UseCardRequest { + + /** + * 扣减的次数 + */ + private Integer deductTimes; + + /** + * 扣减的金额 + */ + private Double deductAmount; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/WechatLoginDto.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/WechatLoginDto.java new file mode 100644 index 0000000..b8f706d --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/WechatLoginDto.java @@ -0,0 +1,29 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotBlank; + +/** + * 微信小程序登录 DTO + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WechatLoginDto { + + // 微信小程序登录 code + @NotBlank(message = "登录code不能为空") + private String code; + + // 手机号code + private String phoneCode; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/WechatOfficialEventDto.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/WechatOfficialEventDto.java new file mode 100644 index 0000000..602f3d3 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/dto/WechatOfficialEventDto.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.member.dto; + +import lombok.Data; + +/** + * 微信服务号事件 DTO + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +public class WechatOfficialEventDto { + + // 微信号 + private String toUserName; + + // 发送方帐号(一个 OpenID) + private String fromUserName; + + // 消息创建时间(整型) + private Long createTime; + + // 消息类型,event + private String msgType; + + // 事件类型:subscribe(关注)/ unsubscribe(取消关注) + private String event; + + // 事件 KEY + private String eventKey; + + // 二维码 ticket(获取二维码图片) + private String ticket; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/BaseEntity.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/BaseEntity.java new file mode 100644 index 0000000..865e4bb --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/BaseEntity.java @@ -0,0 +1,44 @@ +package cn.novalon.gym.manage.member.entity; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.domain.Persistable; +import org.springframework.data.relational.core.mapping.Column; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 会员模块实体基类 + * + * @author 付嘉 + * @date 2026-05-08 + */ +@Data +public abstract class BaseEntity implements Persistable { + + // ID + @Id + private Long id; + + // 创建时间 + @CreatedDate + @Column("created_at") + private LocalDateTime createdAt; + + // 更新时间 + @LastModifiedDate + @Column("updated_at") + private LocalDateTime updatedAt; + + @Column("deleted_at") + private LocalDateTime deletedAt; + + // 判断当前实体是否是新建的 + @Override + public boolean isNew() { + return createdAt == null; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/Member.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/Member.java new file mode 100644 index 0000000..8983e20 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/Member.java @@ -0,0 +1,78 @@ +package cn.novalon.gym.manage.member.entity; + +import lombok.*; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 会员实体类 - 对应 member_user 表 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Table("member_user") +public class Member extends BaseEntity { + + //会员号 + @Column("member_no") + private String memberNo; + + //昵称 + @Column("nickname") + private String nickname; + + //手机号(AES 加密存储 + @Column("phone") + private String phone; + + //性别 + @Column("gender") + private Integer gender; + + //生日 + @Column("birthday") + private LocalDate birthday; + + //地址 + @Column("address") + private String address; + + //是否关注服务号 + @Column("subscribed") + private Boolean subscribed; + + // 最后登录时间 + @Column("last_login_at") + private LocalDateTime lastLoginAt; + + // 头像 + @Column("avatar") + private String avatar; + + // 微信UnionID + @Column("union_id") + private String unionId; + + // 微信OpenID小程序 + @Column("miniapp_open_id") + private String miniappOpenId; + + // 服务号openid + @Column("official_open_id") + private String officialOpenId; + + // 软删除 + @Column("is_deleted") + private Boolean isDeleted; + +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCard.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCard.java new file mode 100644 index 0000000..6d2bd40 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCard.java @@ -0,0 +1,63 @@ +package cn.novalon.gym.manage.member.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +/** + * 会员卡类型实体 - 对应 member_card 表 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Table("member_card") +public class MemberCard extends BaseEntity { + + // 会员卡ID + @Column("member_card_id") + private Long memberCardId; + + // 会员卡名称 + @Column("member_card_name") + private String memberCardName; + + // 会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡 + @Column("member_card_type") + private String memberCardType; + + // 会员卡价格 + @Column("member_card_price") + private Double memberCardPrice; + + // 有效天数(时长卡用) + @Column("member_card_validity_days") + private Integer memberCardValidityDays; + + // 总次数(次卡用) + @Column("member_card_total_times") + private Integer memberCardTotalTimes; + + // 面额(储值卡用) + @Column("member_card_amount") + private Double memberCardAmount; + + // 状态:0-下架, 1-上架 + @Column("member_card_status") + private Integer memberCardStatus; + + // 扩展配置(JSON格式) + @Column("extra_config") + private String extraConfig; + +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCardRecord.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCardRecord.java new file mode 100644 index 0000000..06d00f3 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCardRecord.java @@ -0,0 +1,72 @@ +package cn.novalon.gym.manage.member.entity; + +import cn.novalon.gym.manage.member.enums.MemberCardRecordStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +/** + * 会员卡记录实体(会员持有的卡)- 对应 member_card_record 表 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Table("member_card_record") +public class MemberCardRecord extends BaseEntity { + + // 会员持有卡ID + @Column("member_card_record_id") + private Long memberCardRecordId; + + // 会员ID + @Column("member_id") + private Long memberId; + + // 关联会员卡ID + @Column("member_card_id") + private Long memberCardId; + + // 状态:ACTIVE-有效, USED_UP-用完, EXPIRED-过期, REFUNDED-已退款 + @Column("status") + private MemberCardRecordStatus status; + + // 剩余次数 + @Column("remaining_times") + private Integer remainingTimes; + + // 剩余金额 + @Column("remaining_amount") + private Double remainingAmount; + + // 到期时间 + @Column("expire_time") + private LocalDateTime expireTime; + + // 来源订单ID + @Column("source_order_id") + private Long sourceOrderId; + + // 购买时间 + @Column("purchase_time") + private LocalDateTime purchaseTime; + + // 乐观锁版本号 + @Column("version") + private Integer version; + + // 卡片组成(JSON格式,用于组合卡) + @Column("card_composition") + private String cardComposition; + +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCardTransaction.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCardTransaction.java new file mode 100644 index 0000000..debc15a --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/MemberCardTransaction.java @@ -0,0 +1,77 @@ +package cn.novalon.gym.manage.member.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +/** + * 会员卡交易流水实体 - 对应 member_card_transactions 表 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Table("member_card_transactions") +public class MemberCardTransaction extends BaseEntity { + + // 交易ID + @Column("member_card_record_id") + private Long memberCardRecordId; + + // 会员ID + @Column("member_id") + private Long memberId; + + // 会员卡ID + @Column("member_card_id") + private Long memberCardId; + + // 操作类型:PURCHASE-购买, DEDUCT-扣次/扣费, RENEW-续费, REFUND-退款, EXPIRE-过期 + @Column("operation_type") + private String operationType; + + // 变动次数 + @Column("change_amount") + private Integer changeAmount; + + // 变动金额 + @Column("change_balance") + private Double changeBalance; + + // 变动后剩余次数 + @Column("after_remaining_count") + private Integer afterRemainingCount; + + // 变动后剩余金额 + @Column("after_remaining_balance") + private Double afterRemainingBalance; + + // 关联业务类型:GROUP_CLASS-团课, PT_CLASS-私教, CHECK_IN-签到 + @Column("related_biz_type") + private String relatedBizType; + + // 来源订单ID + @Column("source_order_id") + private Long sourceOrderId; + + // 备注 + @Column("remark") + private String remark; + + // 是否已归档 + @Column("is_archived") + private Boolean isArchived; + + // 归档时间 + @Column("archived_at") + private java.time.LocalDateTime archivedAt; + +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/RefundApplication.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/RefundApplication.java new file mode 100644 index 0000000..74c9e83 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/RefundApplication.java @@ -0,0 +1,65 @@ +package cn.novalon.gym.manage.member.entity; + +import cn.novalon.gym.manage.member.enums.RefundStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 退款申请实体 - 对应 refund_application 表 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Table("refund_application") +public class RefundApplication extends BaseEntity { + + // 会员卡记录ID + @Column("record_id") + private Long recordId; + + // 会员ID + @Column("member_id") + private Long memberId; + + // 状态:PENDING-待审核, APPROVED-已批准, REJECTED-已拒绝, PROCESSING-处理中, SUCCESS-成功, FAILED-失败 + @Column("status") + private RefundStatus status; + + // 退款原因 + @Column("reason") + private String reason; + + // 申请时间 + @Column("apply_time") + private LocalDateTime applyTime; + + // 审核时间 + @Column("audit_time") + private LocalDateTime auditTime; + + // 审核人ID + @Column("auditor_id") + private Long auditorId; + + // 审核备注 + @Column("audit_remark") + private String auditRemark; + + // 退款金额 + @Column("refund_amount") + private BigDecimal refundAmount; + +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/WechatUser.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/WechatUser.java new file mode 100644 index 0000000..2b7a8e0 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/WechatUser.java @@ -0,0 +1,55 @@ +package cn.novalon.gym.manage.member.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +/** + * 微信用户信息 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Table("wechat_user") +public class WechatUser extends BaseEntity { + + // 会员 ID + @Column("member_id") + private Long memberId; + + // 微信 UnionID + @Column("union_id") + private String unionId; + + // 小程序 OpenID + @Column("miniapp_openid") + private String miniappOpenid; + + // 服务号 OpenID + @Column("mp_openid") + private String mpOpenid; + + // 是否关注服务号 + @Column("is_subscribed") + private Boolean isSubscribed; + + // 首次关注时间公众号的时间 + @Column("subscribe_time") + private LocalDateTime subscribeTime; + + // 最后一次取消关注的时间 + @Column("unsubscribe_time") + private LocalDateTime unsubscribeTime; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/BizType.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/BizType.java new file mode 100644 index 0000000..492891f --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/BizType.java @@ -0,0 +1,24 @@ +package cn.novalon.gym.manage.member.enums; + +/** + * 会员卡流水关联业务类型枚举 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public enum BizType { + + GROUP_CLASS("团课"), + PT_CLASS("私教"), + CHECK_IN("签到"); + + private final String desc; + + BizType(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/CardEvent.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/CardEvent.java new file mode 100644 index 0000000..dcf94cc --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/CardEvent.java @@ -0,0 +1,27 @@ +package cn.novalon.gym.manage.member.enums; + +/** + * 会员卡状态机事件枚举 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public enum CardEvent { + + ACTIVATE("激活卡片"), + USE("使用卡片"), + RENEW("续费"), + EXPIRE("过期"), + REFUND("退款"), + DISABLE("禁用"); + + private final String desc; + + CardEvent(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/GenderEnum.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/GenderEnum.java new file mode 100644 index 0000000..9924cfb --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/GenderEnum.java @@ -0,0 +1,37 @@ +package cn.novalon.gym.manage.member.enums; + +import lombok.Getter; + +/** + * 性别枚举 + * + * @author 付嘉 + * @date 2026-05-29 + */ +@Getter +public enum GenderEnum { + + UNKNOWN(0, "未知"), + MALE(1, "男"), + FEMALE(2, "女"); + + private final Integer code; + private final String desc; + + GenderEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + public static GenderEnum fromCode(Integer code) { + if (code == null) { + return UNKNOWN; + } + for (GenderEnum gender : values()) { + if (gender.code.equals(code)) { + return gender; + } + } + return UNKNOWN; + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/MemberCardRecordStatus.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/MemberCardRecordStatus.java new file mode 100644 index 0000000..91bf7df --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/MemberCardRecordStatus.java @@ -0,0 +1,25 @@ +package cn.novalon.gym.manage.member.enums; + +/** + * 会员卡记录状态枚举 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public enum MemberCardRecordStatus { + + ACTIVE("有效"), + USED_UP("用完"), + EXPIRED("过期"), + REFUNDED("已退款"); + + private final String desc; + + MemberCardRecordStatus(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/MemberCardType.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/MemberCardType.java new file mode 100644 index 0000000..7cf18e6 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/MemberCardType.java @@ -0,0 +1,24 @@ +package cn.novalon.gym.manage.member.enums; + +/** + * 会员卡类型枚举 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public enum MemberCardType { + + TIME_CARD("时长卡"), + COUNT_CARD("次卡"), + STORED_VALUE_CARD("储值卡"); + + private final String desc; + + MemberCardType(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/RefundStatus.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/RefundStatus.java new file mode 100644 index 0000000..dbd56ea --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/RefundStatus.java @@ -0,0 +1,27 @@ +package cn.novalon.gym.manage.member.enums; + +/** + * 退款申请状态枚举 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public enum RefundStatus { + + PENDING("待审核"), + APPROVED("已批准"), + REJECTED("已拒绝"), + PROCESSING("处理中"), + SUCCESS("成功"), + FAILED("失败"); + + private final String desc; + + RefundStatus(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/TransactionType.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/TransactionType.java new file mode 100644 index 0000000..7345b80 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/enums/TransactionType.java @@ -0,0 +1,26 @@ +package cn.novalon.gym.manage.member.enums; + +/** + * 会员卡流水操作类型枚举 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public enum TransactionType { + + PURCHASE("购买"), + DEDUCT("扣次/扣费"), + RENEW("续费"), + REFUND("退款"), + EXPIRE("过期"); + + private final String desc; + + TransactionType(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/es/entity/MemberES.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/es/entity/MemberES.java new file mode 100644 index 0000000..1e2e23b --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/es/entity/MemberES.java @@ -0,0 +1,41 @@ +package cn.novalon.gym.manage.member.es.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Document(indexName = "gym_members") +public class MemberES { + + @Id + private String id; + + // 会员号 - 需要搜索(精确匹配) + @Field(type = FieldType.Keyword) + private String memberNo; + + // 昵称 - 需要搜索(模糊搜索) + @Field(type = FieldType.Text) + private String nickname; + + // 手机号 - 需要搜索(精确匹配) + @Field(type = FieldType.Keyword) + private String phone; + + // 性别 - 用于筛选 + @Field(type = FieldType.Integer) + private Integer gender; + + // 头像 - 列表展示 + @Field(type = FieldType.Keyword) + private String avatar; +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/es/repository/MemberESRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/es/repository/MemberESRepository.java new file mode 100644 index 0000000..80064ed --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/es/repository/MemberESRepository.java @@ -0,0 +1,21 @@ +package cn.novalon.gym.manage.member.es.repository; + +import cn.novalon.gym.manage.member.es.entity.MemberES; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.annotations.Query; +import org.springframework.data.elasticsearch.repository.ReactiveElasticsearchRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +/** + * ES 会员数据访问层 + */ +@Repository +public interface MemberESRepository extends ReactiveElasticsearchRepository { + + /** + * 前台通用搜索:会员号(精确匹配) 或 昵称(模糊匹配) 或 手机号(精确匹配)并且 性别筛选(精确匹配) + */ + Flux findByMemberNoOrPhoneOrNicknameContaining( + String memberNo, String phone, String nickname, Pageable pageable); +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/DistributedLockService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/DistributedLockService.java new file mode 100644 index 0000000..3fc995e --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/DistributedLockService.java @@ -0,0 +1,50 @@ +package cn.novalon.gym.manage.member.handler; + +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * 分布式锁服务(简化版,使用本地锁) + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Component +public class DistributedLockService { + + private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + + /** + * 执行带锁的操作(业务操作) + */ + public Mono executeWithLock(String userId, String cardType, Mono operation) { + String lockKey = "lock:member:card:operation:" + userId + ":" + cardType; + ReentrantLock lock = locks.computeIfAbsent(lockKey, k -> new ReentrantLock()); + + lock.lock(); + try { + return operation.doFinally(signalType -> lock.unlock()); + } catch (Exception e) { + lock.unlock(); + return Mono.error(e); + } + } + + /** + * 执行带锁的操作(通用/定时任务) + */ + public Mono executeWithLock(String lockKey, Mono operation) { + ReentrantLock lock = locks.computeIfAbsent(lockKey, k -> new ReentrantLock()); + + lock.lock(); + try { + return operation.doFinally(signalType -> lock.unlock()); + } catch (Exception e) { + lock.unlock(); + return Mono.error(e); + } + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/ExpirationReminderService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/ExpirationReminderService.java new file mode 100644 index 0000000..ef6cead --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/ExpirationReminderService.java @@ -0,0 +1,157 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.RedisZSetCommands; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 会员卡到期提醒服务 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpirationReminderService { + + private final ReactiveStringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final String REMINDER_QUEUE = "queue:member_card_expiration"; + private static final String DEAD_LETTER_QUEUE = "queue:member_card_expiration_dead_letter"; + private static final int REMINDER_DAYS_BEFORE = 7; + private static final long MAX_DELAY_MILLIS = Duration.ofDays(365).toMillis(); + + /** + * 设置到期提醒(购卡/续费时调用) + */ + public Mono scheduleExpirationReminder(MemberCardRecord record) { + if (record.getExpireTime() == null) { + return Mono.empty(); + } + + LocalDateTime now = LocalDateTime.now(); + + Flux reminderFlux = Flux.range(1, REMINDER_DAYS_BEFORE) + .flatMap(daysBefore -> { + LocalDateTime reminderTime = record.getExpireTime().minusDays(daysBefore); + + if (reminderTime.isBefore(now)) { + log.debug("会员卡记录ID={} 的{}天前提醒时间已过,跳过", + record.getMemberCardRecordId(), daysBefore); + return Mono.empty(); + } + + long delayMillis = Duration.between(now, reminderTime).toMillis(); + + if (delayMillis > MAX_DELAY_MILLIS) { + log.warn("会员卡记录ID={} 的{}天后提醒时间超过1年,跳过", + record.getMemberCardRecordId(), daysBefore); + return Mono.empty(); + } + + try { + String messageId = UUID.randomUUID().toString(); + String message = objectMapper.writeValueAsString(new ExpirationMessage( + messageId, + record.getMemberCardRecordId(), + record.getMemberId(), + record.getExpireTime(), + daysBefore + )); + + double executeTime = System.currentTimeMillis() + delayMillis; + + return redisTemplate.opsForZSet() + .add(REMINDER_QUEUE, message, executeTime) + .doOnSuccess(v -> log.info("设置会员卡到期提醒: recordId={}, daysBefore={}, expireTime={}, executeTime={}", + record.getMemberCardRecordId(), daysBefore, record.getExpireTime(), executeTime)) + .then(); + } catch (Exception e) { + log.error("设置会员卡到期提醒失败: recordId={}, daysBefore={}", + record.getMemberCardRecordId(), daysBefore, e); + return Mono.error(e); + } + }); + + return reminderFlux.then(); + } + + /** + * 定时任务:每分钟扫描到期的提醒并发送 + */ + @Scheduled(fixedRate = 60000) + public void processDueReminders() { + double now = System.currentTimeMillis(); + + redisTemplate.opsForZSet() + .rangeByScoreWithScores( + REMINDER_QUEUE, + Range.from(Range.Bound.inclusive(0.0)) + .to(Range.Bound.inclusive(now)), + RedisZSetCommands.Limit.limit().count(100) + ) + .flatMap(tuple -> { + String message = tuple.getValue(); + if (message == null) { + return Mono.empty(); + } + + return Mono.fromCallable(() -> objectMapper.readValue(message, ExpirationMessage.class)) + .flatMap(expirationMessage -> { + log.info("处理到期提醒: messageId={}, memberId={}, expireTime={}, daysBefore={}", + expirationMessage.messageId(), + expirationMessage.memberId(), + expirationMessage.expireTime(), + expirationMessage.daysBefore()); + + // TODO: 集成微信/短信通知服务 + sendNotification(expirationMessage); + + return redisTemplate.opsForZSet() + .remove(REMINDER_QUEUE, message) + .doOnSuccess(removed -> { + if (removed > 0) { + log.info("成功删除已处理的提醒消息"); + } + }); + }) + .onErrorResume(e -> { + log.error("解析到期提醒消息失败,移至死信队列: message={}", message, e); + return redisTemplate.opsForZSet() + .add(DEAD_LETTER_QUEUE, message, System.currentTimeMillis()) + .then(Mono.empty()); + }); + }) + .then() + .subscribe(); + } + + + private void sendNotification(ExpirationMessage reminder) { + // TODO: 实际项目中调用微信模板消息或短信API + log.info("[模拟发送] 会员卡到期提醒 - 会员ID: {}, 到期时间: {}, 提前天数: {}", + reminder.memberId(), reminder.expireTime(), reminder.daysBefore()); + } + + public record ExpirationMessage( + String messageId, + Long recordId, + Long memberId, + LocalDateTime expireTime, + int daysBefore + ) {} +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardHandler.java new file mode 100644 index 0000000..9d052e2 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardHandler.java @@ -0,0 +1,132 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.entity.MemberCard; +import cn.novalon.gym.manage.member.service.IMemberCardService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +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; + +/** + * 会员卡管理处理器 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Component +@Tag(name = "会员卡管理", description = "会员卡类型管理和会员持卡管理") +public class MemberCardHandler { + + private final IMemberCardService memberCardService; + + public MemberCardHandler(IMemberCardService memberCardService) { + this.memberCardService = memberCardService; + } + + @Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情") + public Mono getMemberCardById(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id) + .flatMap(card -> ServerResponse.ok().bodyValue(card)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + @Operation(summary = "查询会员卡类型列表", description = "支持分页和条件查询") + public Mono listMemberCards(ServerRequest request) { + Integer status = request.queryParam("status").map(Integer::valueOf).orElse(null); + String name = request.queryParam("name").orElse(null); + String type = request.queryParam("type").orElse(null); + Double minPrice = request.queryParam("minPrice").map(Double::valueOf).orElse(null); + Double maxPrice = request.queryParam("maxPrice").map(Double::valueOf).orElse(null); + int page = request.queryParam("page").map(Integer::valueOf).orElse(0); + int size = request.queryParam("size").map(Integer::valueOf).orElse(10); + + var pageable = PageRequest.of(page, size); + return ServerResponse.ok() + .body(memberCardService.findWithConditions(status, name, type, minPrice, maxPrice, pageable), + MemberCard.class); + } + + @Operation(summary = "创建会员卡类型", description = "创建新的会员卡类型(时长卡、次卡或储值卡)") + public Mono createMemberCard(ServerRequest request) { + return request.bodyToMono(MemberCard.class) + .flatMap(memberCardService::save) + .flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card)); + } + + @Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息") + public Mono updateMemberCard(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return request.bodyToMono(MemberCard.class) + .flatMap(card -> { + card.setMemberCardId(id); + return memberCardService.save(card); + }) + .flatMap(updated -> ServerResponse.ok().bodyValue(updated)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + @Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型") + public Mono deleteMemberCard(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return memberCardService.logicalDelete(id) + .flatMap(rows -> { + if (rows > 0) { + return ServerResponse.noContent().build(); + } + return ServerResponse.notFound().build(); + }); + } + + @Operation(summary = "购买会员卡", description = "会员购买会员卡,生成会员卡记录") + public Mono purchaseCard(ServerRequest request) { + Long memberId = Long.valueOf(request.queryParam("memberId").orElseThrow()); + Long memberCardId = Long.valueOf(request.queryParam("memberCardId").orElseThrow()); + Long sourceOrderId = request.queryParam("sourceOrderId").map(Long::valueOf).orElse(null); + + return memberCardService.purchaseCard(memberId, memberCardId, sourceOrderId) + .flatMap(record -> ServerResponse.status(HttpStatus.CREATED).bodyValue(record)); + } + + @Operation(summary = "续费会员卡", description = "为已有会员卡续费") + public Mono renewCard(ServerRequest request) { + Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow()); + Integer addTimes = request.queryParam("addTimes").map(Integer::valueOf).orElse(null); + Double addAmount = request.queryParam("addAmount").map(Double::valueOf).orElse(null); + Integer addDays = request.queryParam("addDays").map(Integer::valueOf).orElse(null); + Long sourceOrderId = request.queryParam("sourceOrderId").map(Long::valueOf).orElse(null); + + return memberCardService.renewCard(recordId, addTimes, addAmount, addDays, sourceOrderId) + .flatMap(record -> ServerResponse.ok().bodyValue(record)); + } + + @Operation(summary = "使用会员卡", description = "扣减会员卡次数或余额") + public Mono useCard(ServerRequest request) { + Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow()); + Integer deductTimes = request.queryParam("deductTimes").map(Integer::valueOf).orElse(null); + Double deductAmount = request.queryParam("deductAmount").map(Double::valueOf).orElse(null); + + return memberCardService.useCard(recordId, deductTimes, deductAmount) + .flatMap(record -> ServerResponse.ok().bodyValue(record)); + } + + @Operation(summary = "退款会员卡", description = "申请会员卡退款") + public Mono refundCard(ServerRequest request) { + Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow()); + return memberCardService.refundCard(recordId) + .then(ServerResponse.noContent().build()); + } + + @Operation(summary = "查询有效会员卡", description = "查询指定状态的会员卡类型") + public Mono getActiveCards(ServerRequest request) { + Integer status = request.queryParam("status").map(Integer::valueOf).orElse(1); + return ServerResponse.ok() + .body(memberCardService.findActiveCards(status), MemberCard.class); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardRecordHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardRecordHandler.java new file mode 100644 index 0000000..6d5b091 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardRecordHandler.java @@ -0,0 +1,118 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import cn.novalon.gym.manage.member.service.IMemberCardRecordService; +import cn.novalon.gym.manage.member.service.IMemberCardService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +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; + +/** + * 会员卡记录管理处理器 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Component +@Tag(name = "会员卡记录管理", description = "会员卡购买、续费、使用、退款等核心业务") +public class MemberCardRecordHandler { + + private final IMemberCardService memberCardService; + private final IMemberCardRecordService memberCardRecordService; + + public MemberCardRecordHandler(IMemberCardService memberCardService, + IMemberCardRecordService memberCardRecordService) { + this.memberCardService = memberCardService; + this.memberCardRecordService = memberCardRecordService; + } + + @Operation(summary = "购买会员卡", description = "支持时长卡、次卡、储值卡,自动设置到期提醒") + public Mono purchaseCard(ServerRequest request) { + return request.bodyToMono(PurchaseRequest.class) + .flatMap(body -> memberCardService.purchaseCard( + body.getMemberId(), + body.getMemberCardId(), + body.getSourceOrderId())) + .flatMap(record -> ServerResponse.ok().bodyValue(record)) + .onErrorResume(e -> ServerResponse.badRequest().bodyValue("购买失败: " + e.getMessage())); + } + + @Operation(summary = "续费会员卡", description = "累加剩余次数/余额,顺延到期日期,权益立即生效") + public Mono renewCard(ServerRequest request) { + Long recordId = Long.parseLong(request.pathVariable("recordId")); + return request.bodyToMono(RenewRequest.class) + .flatMap(body -> memberCardService.renewCard(recordId, + body.getAddTimes(), + body.getAddAmount(), + body.getAddDays(), + body.getSourceOrderId())) + .flatMap(record -> ServerResponse.ok().bodyValue(record)) + .onErrorResume(e -> ServerResponse.badRequest().bodyValue("续费失败: " + e.getMessage())); + } + + @Operation(summary = "使用会员卡", description = "预约团课或私教成功后扣减次数或余额") + public Mono useCard(ServerRequest request) { + Long recordId = Long.parseLong(request.pathVariable("recordId")); + return request.bodyToMono(UseCardRequest.class) + .flatMap(body -> memberCardService.useCard(recordId, + body.getDeductTimes(), + body.getDeductAmount())) + .flatMap(record -> ServerResponse.ok().bodyValue(record)) + .onErrorResume(e -> ServerResponse.badRequest().bodyValue("使用失败: " + e.getMessage())); + } + + @Operation(summary = "退款会员卡", description = "使用Saga模式执行退款流程,保证事务一致性") + public Mono refundCard(ServerRequest request) { + Long recordId = Long.parseLong(request.pathVariable("recordId")); + return memberCardService.refundCard(recordId) + .then(ServerResponse.ok().bodyValue("退款成功")) + .onErrorResume(e -> ServerResponse.badRequest().bodyValue("退款失败: " + e.getMessage())); + } + + @Operation(summary = "查询会员卡记录详情", description = "根据记录ID查询详细信息") + public Mono getMemberCardRecordById(ServerRequest request) { + Long recordId = Long.parseLong(request.pathVariable("recordId")); + return memberCardRecordService.findById(recordId) + .flatMap(record -> ServerResponse.ok().bodyValue(record)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + @Operation(summary = "会员我的卡包", description = "查询当前会员的所有有效卡") + public Mono getMyCards(ServerRequest request) { + Long memberId = Long.parseLong(request.pathVariable("memberId")); + return ServerResponse.ok().body( + memberCardRecordService.findActiveCardsByMemberId(memberId), + MemberCardRecord.class); + } + + @Operation(summary = "处理过期会员卡", description = "定时任务调用,扫描并更新过期卡状态") + public Mono processExpiredCards(ServerRequest request) { + return memberCardService.processExpiredCards() + .flatMap(count -> ServerResponse.ok().bodyValue("处理完成,共处理" + count + "条")); + } + + @Data + public static class PurchaseRequest { + private Long memberId; + private Long memberCardId; + private Long sourceOrderId; + } + + @Data + public static class UseCardRequest { + private Integer deductTimes; + private Double deductAmount; + } + + @Data + public static class RenewRequest { + private Integer addTimes; + private Double addAmount; + private Integer addDays; + private Long sourceOrderId; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardScheduledHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardScheduledHandler.java new file mode 100644 index 0000000..047afc3 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardScheduledHandler.java @@ -0,0 +1,102 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.enums.CardEvent; +import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** + * 会员卡定时任务处理器 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MemberCardScheduledHandler { + + private final MemberCardRecordRepository recordRepository; + private final ExpirationReminderService expirationReminderService; + private final MemberCardStateMachine stateMachine; + private final DistributedLockService distributedLockService; + + /** + * 每日凌晨2点检查过期会员卡 + */ + @Scheduled(cron = "0 0 2 * * ?") + public void checkExpiredCards() { + String lockKey = "scheduled:check_expired_cards"; + + distributedLockService.executeWithLock("SYSTEM", "EXPIRE_CHECK", + Mono.fromRunnable(() -> { + log.info("开始执行会员卡过期检查任务"); + + LocalDateTime now = LocalDateTime.now(); + + recordRepository.findActiveRecords() + .filter(record -> record.getExpireTime() != null && record.getExpireTime().isBefore(now)) + .flatMap(record -> + stateMachine.transition(record.getStatus(), CardEvent.EXPIRE) + .flatMap(newState -> { + record.setStatus(newState); + return recordRepository.save(record); + }) + .doOnSuccess(r -> log.info("会员卡记录ID={} 已标记为过期", r.getMemberCardRecordId())) + .onErrorResume(e -> { + log.error("处理会员卡过期失败: recordId={}", record.getMemberCardRecordId(), e); + return Mono.empty(); + }) + ) + .then() + .subscribe(); + }) + ).subscribe(); + } + + /** + * 每日凌晨3点检查是否有遗漏的到期提醒(兜底机制) + */ + @Scheduled(cron = "0 0 3 * * ?") + public void checkAndSendExpirationReminders() { + String lockKey = "scheduled:expiration_reminder"; + + distributedLockService.executeWithLock("SYSTEM", "REMINDER_CHECK", + Mono.fromRunnable(() -> { + log.info("开始执行到期提醒兜底检查任务"); + + LocalDateTime now = LocalDateTime.now(); + + // 查询所有活跃的会员卡 + recordRepository.findActiveRecords() + .filter(record -> record.getExpireTime() != null) + .flatMap(record -> { + try { + // 计算距离到期还有几天 + long daysBetween = java.time.Duration.between(now, record.getExpireTime()).toDays(); + + // 如果到期时间在1-7天范围内,记录日志供人工检查 + if (daysBetween >= 1 && daysBetween <= 7) { + log.warn("发现到期前{}天的会员卡记录ID={},请确认是否已发送提醒", + daysBetween, record.getMemberCardRecordId()); + } + + return Mono.empty(); + } catch (Exception e) { + log.error("检查到期提醒失败: recordId={}", record.getMemberCardRecordId(), e); + return Mono.empty(); + } + }) + .then() + .subscribe(); + }) + ).subscribe(); + + log.info("到期提醒兜底检查任务完成"); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardStateMachine.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardStateMachine.java new file mode 100644 index 0000000..3547a80 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardStateMachine.java @@ -0,0 +1,91 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import cn.novalon.gym.manage.member.enums.CardEvent; +import cn.novalon.gym.manage.member.enums.MemberCardRecordStatus; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +/** + * 会员卡状态机处理器 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Component +public class MemberCardStateMachine { + + private final Map> stateTransitionMap; + + public MemberCardStateMachine() { + this.stateTransitionMap = buildStateTransitionMap(); + } + + private Map> buildStateTransitionMap() { + Map> map = new HashMap<>(); + + // ACTIVE 状态可以转换的事件 + Map activeTransitions = new HashMap<>(); + activeTransitions.put(CardEvent.USE, MemberCardRecordStatus.ACTIVE); + activeTransitions.put(CardEvent.RENEW, MemberCardRecordStatus.ACTIVE); + activeTransitions.put(CardEvent.EXPIRE, MemberCardRecordStatus.EXPIRED); + activeTransitions.put(CardEvent.REFUND, MemberCardRecordStatus.REFUNDED); + map.put(MemberCardRecordStatus.ACTIVE, activeTransitions); + + // USED_UP 状态可以转换的事件 + Map usedUpTransitions = new HashMap<>(); + usedUpTransitions.put(CardEvent.RENEW, MemberCardRecordStatus.ACTIVE); + usedUpTransitions.put(CardEvent.REFUND, MemberCardRecordStatus.REFUNDED); + map.put(MemberCardRecordStatus.USED_UP, usedUpTransitions); + + // EXPIRED 状态可以转换的事件 + Map expiredTransitions = new HashMap<>(); + expiredTransitions.put(CardEvent.RENEW, MemberCardRecordStatus.ACTIVE); + map.put(MemberCardRecordStatus.EXPIRED, expiredTransitions); + + // REFUNDED 状态是终态,不允许任何转换 + + return map; + } + + public Mono canTransition(MemberCardRecordStatus currentState, CardEvent event) { + return Mono.fromSupplier(() -> { + Map transitions = stateTransitionMap.get(currentState); + if (transitions == null) { + return false; + } + return transitions.containsKey(event); + }); + } + + public Mono transition(MemberCardRecordStatus currentState, CardEvent event) { + return Mono.fromSupplier(() -> { + Map transitions = stateTransitionMap.get(currentState); + if (transitions == null || !transitions.containsKey(event)) { + log.error("Invalid state transition: currentState={}, event={}", currentState, event); + throw new IllegalStateException( + String.format("不允许的状态转换: 当前状态=%s, 事件=%s", currentState, event)); + } + MemberCardRecordStatus newState = transitions.get(event); + log.info("State transition: {} --({})--> {}", currentState, event, newState); + return newState; + }); + } + + public Mono validateTransition(MemberCardRecord card, CardEvent event) { + return canTransition(card.getStatus(), event) + .flatMap(canTransition -> { + if (!canTransition) { + return Mono.error(new IllegalStateException( + String.format("会员卡记录ID=%d 不允许的状态转换: 当前状态=%s, 事件=%s", + card.getMemberCardRecordId(), card.getStatus(), event))); + } + return Mono.empty(); + }); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardTransactionHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardTransactionHandler.java new file mode 100644 index 0000000..23f964e --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardTransactionHandler.java @@ -0,0 +1,152 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.hutool.db.PageResult; +import cn.novalon.gym.manage.member.entity.MemberCardTransaction; +import cn.novalon.gym.manage.member.enums.TransactionType; +import cn.novalon.gym.manage.member.service.IMemberCardTransactionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Validator; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +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.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 会员卡流水管理处理器 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Component +@Tag(name = "会员卡流水管理", description = "会员卡流水相关操作") +public class MemberCardTransactionHandler { + + private final IMemberCardTransactionService memberCardTransactionService; + private final Validator validator; + + public MemberCardTransactionHandler(IMemberCardTransactionService memberCardTransactionService, + Validator validator) { + this.memberCardTransactionService = memberCardTransactionService; + this.validator = validator; + } + + /** + * 记录每一次变动 + */ + @Operation(summary = "插入流水记录", description = "购卡、扣次、续费、退款、过期时插入流水") + public Mono insertTransaction(ServerRequest request) { + return request.bodyToMono(MemberCardTransaction.class) + .flatMap(memberCardTransactionService::insertTransaction) + .flatMap(record -> ServerResponse.ok().bodyValue(record)); + } + + /** + * 会员端"使用记录" + */ + @Operation(summary = "会员查询使用记录", description = "按会员ID和时间范围查询流水,支持分页") + public Mono getMemberTransactions(ServerRequest request) { + Long memberId = Long.parseLong(request.pathVariable("memberId")); + LocalDateTime startTime = request.queryParam("startTime") + .map(LocalDateTime::parse).orElse(LocalDateTime.now().minusMonths(1)); + LocalDateTime endTime = request.queryParam("endTime") + .map(LocalDateTime::parse).orElse(LocalDateTime.now()); + int page = request.queryParam("page").map(Integer::parseInt).orElse(0); + int size = request.queryParam("size").map(Integer::parseInt).orElse(10); + Pageable pageable = PageRequest.of(page, size, Sort.by("created_at").descending()); + + return ServerResponse.ok() + .body(memberCardTransactionService.findByMemberIdAndTimeRange( + memberId, startTime, endTime, pageable), MemberCardTransaction.class); + } + + /** + * 后台"使用记录查询"(条件分页) + */ + @Operation(summary = "管理端流水查询", description = "按会员、卡号、操作类型、时间等条件分页查询流水") + public Mono getTransactionsWithConditions(ServerRequest request) { + Long memberId = request.queryParam("memberId").map(Long::parseLong).orElse(null); + Long memberCardId = request.queryParam("memberCardId").map(Long::parseLong).orElse(null); + TransactionType operationType = request.queryParam("operationType") + .map(s -> TransactionType.valueOf(s.toUpperCase())).orElse(null); + LocalDateTime startTime = request.queryParam("startTime").map(LocalDateTime::parse).orElse(null); + LocalDateTime endTime = request.queryParam("endTime").map(LocalDateTime::parse).orElse(null); + int page = request.queryParam("page").map(Integer::parseInt).orElse(0); + int size = request.queryParam("size").map(Integer::parseInt).orElse(10); + Pageable pageable = PageRequest.of(page, size, Sort.by("created_at").descending()); + + Mono countMono = memberCardTransactionService.countWithConditions( + memberId, memberCardId, operationType, startTime, endTime); + Flux flux = memberCardTransactionService.findWithConditions( + memberId, memberCardId, operationType, startTime, endTime, pageable); + + return Mono.zip(countMono, flux.collectList()) + .flatMap(tuple -> { + Long total = tuple.getT1(); + List list = tuple.getT2(); + // 构造 PageResult,内部自动计算总页数 + PageResult result = new PageResult<>(page, size, total.intValue()); + result.addAll(list); + return ServerResponse.ok().bodyValue(result); + }); + } + + /** + * 按卡ID查询流水 + */ + @Operation(summary = "按卡ID查询流水", description = "查看某张卡的所有流水记录") + public Mono getTransactionsByCardId(ServerRequest request) { + Long memberCardId = Long.parseLong(request.pathVariable("cardId")); + return ServerResponse.ok() + .body(memberCardTransactionService.findByMemberCardId(memberCardId), + MemberCardTransaction.class); + } + + /** + * 统计某卡种的总扣次数 + */ + @Operation(summary = "统计卡种总扣次数", description = "按卡种ID和时间范围统计扣次总数") + public Mono getDeductCountByCardId(ServerRequest request) { + Long memberCardId = Long.parseLong(request.pathVariable("cardId")); + LocalDateTime startTime = request.queryParam("startTime") + .map(LocalDateTime::parse).orElse(LocalDateTime.now().minusMonths(1)); + LocalDateTime endTime = request.queryParam("endTime") + .map(LocalDateTime::parse).orElse(LocalDateTime.now()); + return memberCardTransactionService.sumDeductCountByCardId(memberCardId, startTime, endTime) + .flatMap(count -> ServerResponse.ok().bodyValue(count)); + } + + /** + * 统计某时间段的续费总金额 + */ + @Operation(summary = "统计续费总金额", description = "按时间段统计续费总金额") + public Mono getRenewAmountByTimeRange(ServerRequest request) { + LocalDateTime startTime = request.queryParam("startTime") + .map(LocalDateTime::parse).orElse(LocalDateTime.now().minusMonths(1)); + LocalDateTime endTime = request.queryParam("endTime") + .map(LocalDateTime::parse).orElse(LocalDateTime.now()); + return memberCardTransactionService.sumRenewAmountByTimeRange(startTime, endTime) + .flatMap(amount -> ServerResponse.ok().bodyValue(amount)); + } + + /** + * 统计某会员的购卡总金额 + */ + @Operation(summary = "统计会员购卡总金额", description = "按会员ID和时间段统计购卡总金额") + public Mono getPurchaseAmountByMember(ServerRequest request) { + Long memberId = Long.parseLong(request.pathVariable("memberId")); + LocalDateTime startTime = request.queryParam("startTime") + .map(LocalDateTime::parse).orElse(LocalDateTime.now().minusMonths(1)); + LocalDateTime endTime = request.queryParam("endTime") + .map(LocalDateTime::parse).orElse(LocalDateTime.now()); + return memberCardTransactionService.sumPurchaseAmountByMemberId(memberId, startTime, endTime) + .flatMap(amount -> ServerResponse.ok().bodyValue(amount)); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java new file mode 100644 index 0000000..baa75f7 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java @@ -0,0 +1,241 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.config.WechatProperties; +import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto; +import cn.novalon.gym.manage.member.dto.SearchMemberDto; +import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto; +import cn.novalon.gym.manage.member.service.MemberService; +import cn.novalon.gym.manage.member.service.WechatAuthService; +import cn.novalon.gym.manage.member.service.WechatOfficialService; +import cn.novalon.gym.manage.member.util.AesUtil; +import cn.novalon.gym.manage.member.util.WechatPhoneUtil; +import cn.novalon.gym.manage.sys.util.AuthUtil; +import cn.novalon.gym.manage.sys.security.JwtTokenProvider; +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.apache.commons.lang3.math.NumberUtils; +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; + +/** + * 会员信息处理器 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Component +@RequiredArgsConstructor +@Tag(name = "会员管理", description = "会员信息管理、微信绑定、服务号关注等") +public class MemberHandler { + + private final MemberService memberService; + private final WechatAuthService wechatAuthService; + private final WechatOfficialService wechatOfficialService; + private final AuthUtil authUtil; + + @Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息") + public Mono getMemberInfo(ServerRequest request) { + + Long memberId = authUtil.getMemberIdOrThrow(request); + + log.info("获取会员信息, memberId: {}", memberId); + + return memberService.getMemberInfo(memberId) + .flatMap(info -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(info)); + } + + @Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息") + public Mono updateMemberInfo(ServerRequest request) { + + Long memberId = authUtil.getMemberIdOrThrow(request); + + log.info("更新会员信息, memberId: {}", memberId); + + return request.bodyToMono(UpdateMemberInfoDto.class) + .flatMap(updateDto -> memberService.updateMemberInfo(memberId, updateDto)) + .flatMap(info -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(info)); + } + + @Operation(summary = "绑定手机号", description = "通过微信小程序手机号code绑定会员手机号") + public Mono bindPhone(ServerRequest request) { + + Long memberId = authUtil.getMemberIdOrThrow(request); + + String phoneCode = request.queryParam("phoneCode").orElse(""); + + if (phoneCode.trim().isEmpty()) throw new IllegalArgumentException("手机号code不能为空"); + + log.info("收到绑定手机号请求, memberId: {}, phoneCode: {}", memberId, phoneCode); + + return wechatAuthService.bindPhone(memberId, phoneCode) + .flatMap(success -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(success)); + } + + @Operation(summary = "查询服务号关注状态", description = "查询会员是否关注微信服务号") + public Mono checkSubscribeStatus(ServerRequest request) { + + Long memberId = authUtil.getMemberIdOrThrow(request); + + log.info("查询服务号关注状态, memberId: {}", memberId); + + return wechatOfficialService.checkSubscribeStatus(memberId) + .flatMap(subscribed -> { + return ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(subscribed); + }); + } + + @Operation(summary = "管理员更新手机号", description = "后台管理员为会员更新手机号") + public Mono adminUpdatePhone(ServerRequest request) { + + Long adminId = authUtil.getMemberIdOrThrow(request); + + String memberIdStr = request.pathVariable("id"); + long memberId = NumberUtils.toLong(memberIdStr, 0L); + + if (memberId <= 0) throw new IllegalArgumentException("更新手机号失败: memberId 无效"); + + log.info("收到更新手机号请求, memberId: {}", memberId); + + return request.bodyToMono(AdminUpdatePhoneDto.class) + .flatMap(body -> { + String phone = body.getPhone(); + + if (phone == null || phone.isEmpty()) return Mono.error(new IllegalArgumentException("手机号不能为空")); + + if (!phone.matches("^1[3-9]\\d{9}$")) return Mono.error(new IllegalArgumentException("手机号格式不正确")); + + log.info("开始更新手机号, memberId: {}, phone: {}", memberId, phone); + + return memberService.adminUpdatePhone(memberId, phone); + }) + .flatMap(success -> { + log.info("手机号更新成功, memberId: {}", memberId); + return ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(success); + }); + } + + @Operation(summary = "管理员查看会员详情", description = "后台管理员查看指定会员的详细信息") + public Mono adminGetMemberInfo(ServerRequest request) { + + Long adminId = authUtil.getMemberIdOrThrow(request); + + String memberIdStr = request.pathVariable("id"); + long memberId = NumberUtils.toLong(memberIdStr, 0L); + if(memberId <= 0) throw new IllegalArgumentException("会员ID格式错误"); + + log.info("前台查看会员信息, adminId: {}, memberId: {}", adminId, memberId); + + return memberService.getMemberDetail(memberId) + .flatMap(detail -> { + if (detail.getPhone() != null && !detail.getPhone().isEmpty()) { + try { + String decryptedPhone = AesUtil.decrypt(detail.getPhone()); + detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); + } catch (Exception e) { + log.error("手机号解密失败, memberId: {}", detail.getId(), e); + detail.setPhone(null); + } + } + return ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(detail); + }); + } + + @Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息") + public Mono adminUpdateMemberInfo(ServerRequest request) { + + Long adminId = authUtil.getMemberIdOrThrow(request); + + String memberIdStr = request.pathVariable("id"); + long memberId = NumberUtils.toLong(memberIdStr, 0L); + if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误"); + + // TODO: 补充签到记录 + log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId); + + return request.bodyToMono(UpdateMemberInfoDto.class) + .flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto)) + .flatMap(detail -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(detail)); + } + + @Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页") + public Mono searchMembers(ServerRequest request) { + + Long adminId = authUtil.getMemberIdOrThrow(request); + + String keyword = request.queryParam("searchValue").orElse(null); + Integer pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); + Integer pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10); + + log.info("前台搜索会员列表, adminId: {}, keyword: {},pageNum: {}, pageSize: {}", + adminId, keyword, pageNum, pageSize); + + return memberService.searchMember(new SearchMemberDto(keyword, pageNum, pageSize)) + .map(member -> { + // 解密手机号 + if (member.getPhone() != null && !member.getPhone().isEmpty()) { + try { + String decryptedPhone = AesUtil.decrypt(member.getPhone()); + member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); + } catch (Exception e) { + log.error("手机号解密失败, memberId: {}", member.getId(), e); + member.setPhone(null); + } + } + return member; + }) + .collectList() + .flatMap(list -> ServerResponse.ok().bodyValue(list)); + } + + + @Operation(summary = "查看会员列表", description = "后台管理员分页查看所有会员列表") + public Mono getAllMembers(ServerRequest request) { + + Long adminId = authUtil.getMemberIdOrThrow(request); + + int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); + int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10); + + log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize); + // TODO: 补充签到记录 + + return memberService.findAll(pageNum, pageSize) + .map(member -> { + // 解密手机号 + if (member.getPhone() != null && !member.getPhone().isEmpty()) { + try { + String decryptedPhone = AesUtil.decrypt(member.getPhone()); + member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); + } catch (Exception e) { + log.error("手机号解密失败, memberId: {}", member.getId(), e); + member.setPhone(null); + } + } + return member; + }) + .collectList() + .flatMap(list -> ServerResponse.ok().bodyValue(list)); + } + +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/RefundSagaHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/RefundSagaHandler.java new file mode 100644 index 0000000..de914c7 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/RefundSagaHandler.java @@ -0,0 +1,134 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import cn.novalon.gym.manage.member.entity.MemberCardTransaction; +import cn.novalon.gym.manage.member.enums.CardEvent; +import cn.novalon.gym.manage.member.enums.MemberCardRecordStatus; +import cn.novalon.gym.manage.member.enums.TransactionType; +import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; +import cn.novalon.gym.manage.member.service.IMemberCardTransactionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; + +/** + * 退款 Saga 处理器 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RefundSagaHandler { + + private final MemberCardRecordRepository recordRepository; + private final IMemberCardTransactionService transactionService; + private final MemberCardStateMachine stateMachine; + + public Mono executeRefund(Long recordId) { + return recordRepository.findById(recordId) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在"))) + .flatMap(record -> stateMachine.validateTransition(record, CardEvent.REFUND) + .then(Mono.defer(() -> doExecuteRefund(recordId, record)))); + } + + private Mono doExecuteRefund(Long recordId, MemberCardRecord record) { + List steps = new ArrayList<>(); + List rollbackSteps = new ArrayList<>(); + + SagaStep step1 = new SagaStep( + "更新会员卡状态为已退款", + updateCardStatus(recordId, MemberCardRecordStatus.REFUNDED), + Mono.defer(() -> updateCardStatus(recordId, record.getStatus())) + ); + steps.add(step1); + rollbackSteps.add(0, step1); + + SagaStep step2 = new SagaStep( + "记录退款流水", + createRefundTransaction(record), + createReversalTransaction(record) + ); + steps.add(step2); + rollbackSteps.add(0, step2); + + return executeSaga(steps, rollbackSteps); + } + + private Mono updateCardStatus(Long recordId, MemberCardRecordStatus status) { + return recordRepository.updateStatus(recordId, status.name()) + .flatMap(rows -> { + if (rows == 0) { + return Mono.error(new RuntimeException("更新会员卡状态失败")); + } + return Mono.empty(); + }); + } + + private Mono createRefundTransaction(MemberCardRecord record) { + MemberCardTransaction transaction = MemberCardTransaction.builder() + .memberId(record.getMemberId()) + .memberCardId(record.getMemberCardId()) + .operationType(TransactionType.REFUND.name()) + .changeAmount(-record.getRemainingTimes()) + .changeBalance(-record.getRemainingAmount()) + .afterRemainingCount(0) + .afterRemainingBalance(0.0) + .remark("会员卡退款") + .build(); + + return transactionService.createTransaction(transaction); + } + + private Mono createReversalTransaction(MemberCardRecord record) { + MemberCardTransaction reversal = MemberCardTransaction.builder() + .memberId(record.getMemberId()) + .memberCardId(record.getMemberCardId()) + .operationType(TransactionType.REFUND.name()) + .changeAmount(record.getRemainingTimes()) + .changeBalance(record.getRemainingAmount()) + .remark("退款冲正") + .build(); + + return transactionService.createTransaction(reversal); + } + + private Mono executeSaga(List steps, List rollbackSteps) { + return executeStep(steps, 0, rollbackSteps); + } + + private Mono executeStep(List steps, int index, List rollbackSteps) { + if (index >= steps.size()) { + return Mono.empty(); + } + + SagaStep currentStep = steps.get(index); + + return currentStep.operation() + .then(Mono.defer(() -> executeStep(steps, index + 1, rollbackSteps))) + .onErrorResume(error -> { + log.error("Saga步骤执行失败: step={}, error={}", currentStep.description(), error.getMessage()); + return rollback(rollbackSteps, 0).then(Mono.error(error)); + }); + } + + private Mono rollback(List rollbackSteps, int index) { + if (index >= rollbackSteps.size()) { + return Mono.empty(); + } + + SagaStep currentStep = rollbackSteps.get(index); + + return currentStep.rollbackOperation() + .then(Mono.defer(() -> rollback(rollbackSteps, index + 1))) + .doOnError(error -> log.error("Saga回滚失败: step={}, error={}", currentStep.description(), error.getMessage())) + .onErrorResume(e -> Mono.empty()); + } + + private record SagaStep(String description, Mono operation, Mono rollbackOperation) {} +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/WechatAuthHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/WechatAuthHandler.java new file mode 100644 index 0000000..290a277 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/WechatAuthHandler.java @@ -0,0 +1,57 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.dto.WechatLoginDto; +import cn.novalon.gym.manage.member.service.WechatAuthService; +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; + +/** + * 微信认证 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Component +@RequiredArgsConstructor +@Tag(name = "微信认证", description = "微信小程序登录、公众号回调等") +public class WechatAuthHandler { + + private final WechatAuthService wechatAuthService; + private final WechatOfficialEventHandler wechatOfficialEventHandler; + + @Operation(summary = "微信小程序登录", description = "通过微信小程序code获取session_key,完成会员登录或注册") + public Mono miniappLogin(ServerRequest request) { + log.info("收到小程序登录请求"); + + return request.bodyToMono(WechatLoginDto.class) + .flatMap(loginRequest -> { + log.info("开始微信AuthService, code: {}", loginRequest.getCode()); + return wechatAuthService.miniappLogin(loginRequest); + }) + .flatMap(response -> { + log.info("更新成功, memberId: {}", response.getMemberId()); + return ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(response); + }); + } + + @Operation(summary = "微信公众号回调", description = "处理微信公众号事件(关注、取消关注等)") + public Mono mpCallback(ServerRequest request) { + return wechatOfficialEventHandler.handleEvent(request); + } + + @Operation(summary = "验证微信公众号签名", description = "微信公众号服务器验证,返回echostr") + public Mono verifyMpSignature(ServerRequest request) { + return wechatOfficialEventHandler.verifySignature(request); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/WechatOfficialEventHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/WechatOfficialEventHandler.java new file mode 100644 index 0000000..042cb7e --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/WechatOfficialEventHandler.java @@ -0,0 +1,215 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.config.WechatProperties; +import cn.novalon.gym.manage.member.service.WechatOfficialService; +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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; + +/** + * 微信公众号事件处理器 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Component +@RequiredArgsConstructor +public class WechatOfficialEventHandler { + + private final WechatOfficialService wechatOfficialService; + private final WechatProperties wechatProperties; + + /** + * 处理微信公众号事件 + * + * 请求格式:XML + * 响应格式:success 或 回复消息内容 + */ + public Mono handleEvent(ServerRequest request) { + return request.bodyToMono(String.class) + .flatMap(xmlBody -> { + log.info("收到微信公众号事件 {}", xmlBody); + + String openId = extractOpenId(xmlBody); + String event = extractEvent(xmlBody); + + if (openId == null || event == null) { + log.error("无法解析微信公众号事件"); + return ServerResponse.badRequest().bodyValue("error"); + } + + log.info("处理事件 openId={}, event={}", openId, event); + + // 根据事件类型处理 + if ("subscribe".equals(event)) { + return wechatOfficialService.handleSubscribeEvent(openId) + .then(ServerResponse.ok() + .contentType(MediaType.TEXT_PLAIN) + .bodyValue("success")); + } else if ("unsubscribe".equals(event)) { + return wechatOfficialService.handleUnsubscribeEvent(openId) + .then(ServerResponse.ok() + .contentType(MediaType.TEXT_PLAIN) + .bodyValue("success")); + } else { + log.warn("未知事件类型: {}", event); + return ServerResponse.ok() + .contentType(MediaType.TEXT_PLAIN) + .bodyValue("success"); + } + }) + .onErrorResume(e -> { + log.error("处理微信公众号事件失败", e); + return ServerResponse.ok() + .contentType(MediaType.TEXT_PLAIN) + .bodyValue("success"); // 即使处理失败也返回success避免微信重试 + }); + } + + /** + * 验证微信公众号签名 + * + * GET请求用于验证服务器地址 + */ + public Mono verifySignature(ServerRequest request) { + String signature = request.queryParam("signature").orElse(""); + String timestamp = request.queryParam("timestamp").orElse(""); + String nonce = request.queryParam("nonce").orElse(""); + String echostr = request.queryParam("echostr").orElse(""); + + log.info("========== 微信公众号签名验证 =========="); + log.info("收到的参数:"); + log.info(" signature: {}", signature); + log.info(" timestamp: {}", timestamp); + log.info(" nonce: {}", nonce); + log.info(" echostr: {}", echostr); + + // 获取配置的Token + String token = wechatProperties.getMp().getToken(); + log.info("配置的Token: {}", token); + + // 验证签名 + if (checkSignature(signature, timestamp, nonce, token)) { + log.info("签名验证成功,返回echostr: {}", echostr); + log.info("========== 微信公众号签名验证结束 =========="); + return ServerResponse.ok() + .contentType(MediaType.TEXT_PLAIN) + .bodyValue(echostr); + } else { + log.warn("签名验证失败"); + log.info("========== 微信公众号签名验证结束 =========="); + return ServerResponse.badRequest() + .contentType(MediaType.TEXT_PLAIN) + .bodyValue("error"); + } + } + + /** + * 验证签名 + * + * @param signature 微信加密签名 + * @param timestamp 时间戳 + * @param nonce 随机数 + * @param token Token + * @return 是否验证通过 + */ + private boolean checkSignature(String signature, String timestamp, String nonce, String token) { + // 1. 将token、timestamp、nonce三个参数进行字典序排序 + String[] arr = new String[]{token, timestamp, nonce}; + Arrays.sort(arr); + + // 2. 将三个参数字符串拼接成一个字符串 + StringBuilder sb = new StringBuilder(); + for (String str : arr) { + sb.append(str); + } + + // 3. 将拼接后的字符串进行sha1加密 + String encrypted = sha1(sb.toString()); + log.debug("计算的签名 {}", encrypted); + + // 4. 将加密后的字符串与signature对比 + return encrypted != null && encrypted.equalsIgnoreCase(signature); + } + + /** + * SHA1加密 + * + * @param str 待加密字符串 + * @return 加密后字符串 + */ + private String sha1(String str) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(str.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : digest) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (Exception e) { + log.error("SHA1加密失败", e); + return null; + } + } + + /** + * 从XML中提取OpenID + */ + private String extractOpenId(String xml) { + int start = xml.indexOf(""); + int end = xml.indexOf(""); + if (start != -1 && end != -1) { + String value = xml.substring(start + 14, end); + // 去除 CDATA 标记 + return cleanCdata(value); + } + return null; + } + + /** + * 从XML中获取事件类型 + */ + private String extractEvent(String xml) { + int start = xml.indexOf(""); + int end = xml.indexOf(""); + if (start != -1 && end != -1) { + String value = xml.substring(start + 7, end); + // 去除 CDATA 标记 + return cleanCdata(value); + } + return null; + } + + /** + * 清理 CDATA 标记 + * 例如: -> subscribe + */ + private String cleanCdata(String value) { + if (value == null) { + return null; + } + // 去除前后空白 + value = value.trim(); + // 提取 CDATA 中间内容 + // 格式: + if (value.startsWith("")) { + return value.substring(9, value.length() - 3); + } + return value; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/IMemberRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/IMemberRepository.java new file mode 100644 index 0000000..d6c868e --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/IMemberRepository.java @@ -0,0 +1,75 @@ +package cn.novalon.gym.manage.member.repository; + +import cn.novalon.gym.manage.member.entity.Member; +import cn.novalon.gym.manage.member.vo.MemberCardInfoVO; +import org.springframework.data.domain.Pageable; +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; + +/** + * 会员Repository + * @author 付嘉 + * @date 2026-05-01 + */ + +@Repository +public interface IMemberRepository extends R2dbcRepository { + + // UnionID查询会员 + Mono findByUnionId(String unionId); + + // 小程序OpenID查询会员 + Mono findByMiniappOpenId(String miniappOpenId); + + // 服务号OpenID查询会员 + Mono findByOfficialOpenId(String officialOpenId); + + // 手机号查询 + Mono findByPhone(String phone); + + /** + * 分页查询所有会员 + */ + Flux findAllBy(Pageable pageable); + + /** + * 查询会员的所有卡片 + */ + @Query("SELECT " + + " r.id, " + + " r.member_card_record_id, " + + " r.member_id, " + + " r.member_card_id, " + + " r.status, " + + " r.remaining_times, " + + " r.remaining_amount, " + + " r.expire_time, " + + " r.purchase_time, " + + " r.source_order_id, " + + " r.created_at, " + + " r.updated_at, " + + " r.version, " + + " r.card_composition, " + + " c.id AS card_id, " + + " c.member_card_id, " + + " c.member_card_name, " + + " c.member_card_type, " + + " c.member_card_price, " + + " c.member_card_validity_days, " + + " c.member_card_total_times, " + + " c.member_card_amount, " + + " c.member_card_status, " + + " c.extra_config, " + + " c.created_at AS card_created_at, " + + " c.updated_at AS card_updated_at " + + "FROM member_card_record r " + + "LEFT JOIN member_card c ON r.member_card_id = c.id " + + "WHERE r.member_id = :memberId " + + "AND r.deleted_at IS NULL " + + "AND c.deleted_at IS NULL " + + "ORDER BY r.created_at DESC") + Flux findCardRecordsWithCardInfoByMemberId(Long memberId); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRecordRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRecordRepository.java new file mode 100644 index 0000000..8c3d6f1 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRecordRepository.java @@ -0,0 +1,109 @@ +package cn.novalon.gym.manage.member.repository; + +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import org.springframework.data.domain.Pageable; +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(会员持有的卡) + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Repository +public interface MemberCardRecordRepository extends R2dbcRepository { + + /** + * 插入新的激活卡记录 + */ + @Modifying + @Query("INSERT INTO member_card_record (member_id, member_card_id, status, expire_time, remaining_times, remaining_amount, source_order_id, purchase_time, created_at, updated_at) " + + "VALUES (:memberId, :memberCardId, 'ACTIVE', :expireTime, :remainingTimes, :remainingAmount, :sourceOrderId, NOW(), NOW(), NOW()) " + + "RETURNING *") + Mono insertActiveRecord(Long memberId, Long memberCardId, + LocalDateTime expireTime, Integer remainingTimes, + Double remainingAmount, Long sourceOrderId); + + /** + * 扣减使用次数/金额 + */ + @Modifying + @Query("UPDATE member_card_record SET " + + "remaining_times = remaining_times - :deductTimes, " + + "remaining_amount = remaining_amount - :deductAmount, " + + "updated_at = NOW() " + + "WHERE member_card_record_id = :recordId " + + "AND deleted_at IS NULL " + + "AND remaining_times >= :deductTimes " + + "AND remaining_amount >= :deductAmount") + Mono deductUsage(Long recordId, Integer deductTimes, Double deductAmount); + + /** + * 续费卡片 + */ + @Modifying + @Query("UPDATE member_card_record SET remaining_times = remaining_times + :addTimes, " + + "remaining_amount = remaining_amount + :addAmount, expire_time = :newExpireTime, updated_at = NOW() " + + "WHERE member_card_record_id = :recordId AND deleted_at IS NULL") + Mono renewCard(Long recordId, Integer addTimes, Double addAmount, LocalDateTime newExpireTime); + + /** + * 更新卡片状态 + */ + @Modifying + @Query("UPDATE member_card_record SET status = :status, updated_at = NOW() " + + "WHERE member_card_record_id = :recordId AND deleted_at IS NULL") + Mono updateStatus(Long recordId, String status); + + /** + * 查询会员的有效卡片 + */ + @Query("SELECT * FROM member_card_record WHERE member_id = :memberId AND status = 'ACTIVE' AND deleted_at IS NULL ORDER BY expire_time ASC") + Flux findActiveCardsByMemberId(Long memberId); + + /** + * 查询会员的所有卡片(分页) + */ + @Query("SELECT mcr.* FROM member_card_record mcr " + + "INNER JOIN member_user m ON mcr.member_id = m.id " + + "WHERE mcr.member_id = :memberId AND mcr.deleted_at IS NULL " + + "ORDER BY mcr.purchase_time DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}") + Flux findByMemberId(Long memberId, Pageable pageable); + + /** + * 验证次卡是否有足够次数 + */ + @Query("SELECT * FROM member_card_record WHERE member_card_record_id = :recordId " + + "AND status = 'ACTIVE' AND deleted_at IS NULL " + + "AND expire_time > NOW() " + + "AND remaining_times >= :requiredTimes") + Mono validateCountCard(Long recordId, Integer requiredTimes); + + /** + * 验证储值卡是否有足够余额 + */ + @Query("SELECT * FROM member_card_record WHERE member_card_record_id = :recordId " + + "AND status = 'ACTIVE' AND deleted_at IS NULL " + + "AND expire_time > NOW() " + + "AND remaining_amount >= :requiredAmount") + Mono validateStoredCard(Long recordId, Double requiredAmount); + + /** + * 查询已过期的卡片 + */ + @Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND expire_time < NOW() AND deleted_at IS NULL LIMIT 500") + Flux findExpiredCards(); + + /** + * 查询所有有效记录 + */ + @Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND deleted_at IS NULL") + Flux findActiveRecords(); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRepository.java new file mode 100644 index 0000000..b3820ae --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRepository.java @@ -0,0 +1,91 @@ +package cn.novalon.gym.manage.member.repository; + +import cn.novalon.gym.manage.member.entity.MemberCard; +import org.springframework.data.domain.Pageable; +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; + +/** + * 会员卡类型 Repository + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Repository +public interface MemberCardRepository extends R2dbcRepository { + + /** + * 根据会员卡ID查询(未删除的) + */ + Mono findByMemberCardIdAndDeletedAtIsNull(Long memberCardId); + + /** + * 条件查询会员卡列表 + */ + @Query("SELECT * FROM member_card WHERE deleted_at IS NULL " + + "AND (:status IS NULL OR member_card_status = :status) " + + "AND (:name IS NULL OR member_card_name LIKE CONCAT('%', :name, '%')) " + + "AND (:type IS NULL OR member_card_type = :type) " + + "AND (:minPrice IS NULL OR member_card_price >= :minPrice) " + + "AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " + + "ORDER BY created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}") + Flux findWithConditions(Integer status, String name, String type, + Double minPrice, Double maxPrice, Pageable pageable); + + /** + * 统计符合条件的会员卡总数 + */ + @Query("SELECT COUNT(*) FROM member_card WHERE deleted_at IS NULL " + + "AND (:status IS NULL OR member_card_status = :status) " + + "AND (:name IS NULL OR member_card_name LIKE CONCAT('%', :name, '%')) " + + "AND (:type IS NULL OR member_card_type = :type) " + + "AND (:minPrice IS NULL OR member_card_price >= :minPrice) " + + "AND (:maxPrice IS NULL OR member_card_price <= :maxPrice)") + Mono countWithConditions(Integer status, String name, String type, + Double minPrice, Double maxPrice); + + /** + * 按状态查询会员卡列表 + */ + Flux findByMemberCardStatusAndDeletedAtIsNull(Integer status, Pageable pageable); + + /** + * 检查会员卡是否已被购买 + */ + @Query("SELECT EXISTS(SELECT 1 FROM member_card_record WHERE member_card_id = :memberCardId AND deleted_at IS NULL LIMIT 1)") + Mono existsPurchasedRecord(Long memberCardId); + + /** + * 逻辑删除会员卡 + */ + @Modifying + @Query("UPDATE member_card SET deleted_at = NOW() WHERE member_card_id = :memberCardId AND deleted_at IS NULL") + Mono logicalDelete(Long memberCardId); + + /** + * 安全更新会员卡信息 + */ + @Modifying + @Query("UPDATE member_card SET " + + "member_card_name = COALESCE(:name, member_card_name), " + + "member_card_price = COALESCE(:price, member_card_price), " + + "member_card_validity_days = COALESCE(:durationDays, member_card_validity_days), " + + "member_card_total_times = COALESCE(:totalCount, member_card_total_times), " + + "member_card_amount = COALESCE(:denomination, member_card_amount), " + + "member_card_status = COALESCE(:status, member_card_status), " + + "updated_at = NOW() " + + "WHERE member_card_id = :memberCardId AND deleted_at IS NULL") + Mono updateSafe(Long memberCardId, String name, Double price, + Integer durationDays, Integer totalCount, + Double denomination, Integer status); + + /** + * 批量查询上架的会员卡 + */ + @Query("SELECT * FROM member_card WHERE deleted_at IS NULL AND member_card_status = :status ORDER BY member_card_price ASC") + Flux findActiveCards(Integer status); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardTransactionRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardTransactionRepository.java new file mode 100644 index 0000000..8f9d040 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardTransactionRepository.java @@ -0,0 +1,121 @@ +package cn.novalon.gym.manage.member.repository; + +import cn.novalon.gym.manage.member.entity.MemberCardTransaction; +import org.springframework.data.domain.Pageable; +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 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Repository +public interface MemberCardTransactionRepository extends R2dbcRepository { + + /** + * 插入交易流水记录 + */ + @Modifying + @Query("INSERT INTO member_card_transactions (member_card_record_id, member_card_id, member_id, operation_type, change_amount, " + + "change_balance, after_remaining_count, after_remaining_balance, related_biz_type, source_order_id, remark, created_at) " + + "VALUES (:memberCardRecordId, :memberCardId, :memberId, :operationType, :changeAmount, :changeBalance, " + + ":afterRemainingCount, :afterRemainingBalance, :relatedBizType, :sourceOrderId, :remark, NOW()) " + + "RETURNING *") + Mono insertTransaction(Long memberCardRecordId, Long memberCardId, Long memberId, + String operationType, Integer changeAmount, + Double changeBalance, Integer afterRemainingCount, + Double afterRemainingBalance, String relatedBizType, + Long sourceOrderId, String remark); + + /** + * 查询会员的使用记录(按时间范围) + */ + @Query("SELECT * FROM member_card_transactions WHERE member_id = :memberId " + + "AND created_at BETWEEN :startTime AND :endTime " + + "ORDER BY created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}") + Flux findByMemberIdAndTimeRange(Long memberId, + LocalDateTime startTime, + LocalDateTime endTime, + Pageable pageable); + + /** + * 条件查询流水记录 + */ + @Query("SELECT * FROM member_card_transactions " + + "AND (:memberId IS NULL OR member_id = :memberId) " + + "AND (:memberCardId IS NULL OR member_card_id = :memberCardId) " + + "AND (:operationType IS NULL OR operation_type = :operationType) " + + "AND (:startTime IS NULL OR created_at >= :startTime) " + + "AND (:endTime IS NULL OR created_at <= :endTime) " + + "ORDER BY created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}") + Flux findWithConditions(Long memberId, Long memberCardId, + String operationType, + LocalDateTime startTime, + LocalDateTime endTime, + Pageable pageable); + + /** + * 统计符合条件的流水总数 + */ + @Query("SELECT COUNT(*) FROM member_card_transactions " + + "AND (:memberId IS NULL OR member_id = :memberId) " + + "AND (:memberCardId IS NULL OR member_card_id = :memberCardId) " + + "AND (:operationType IS NULL OR operation_type = :operationType) " + + "AND (:startTime IS NULL OR created_at >= :startTime) " + + "AND (:endTime IS NULL OR created_at <= :endTime)") + Mono countWithConditions(Long memberId, Long memberCardId, + String operationType, + LocalDateTime startTime, + LocalDateTime endTime); + + /** + * 按会员卡ID查询所有流水 + */ + @Query("SELECT * FROM member_card_transactions WHERE member_card_id = :memberCardId ORDER BY created_at DESC") + Flux findByMemberCardId(Long memberCardId); + + /** + * 按会员ID查询所有流水 + */ + @Query("SELECT * FROM member_card_transactions WHERE member_id = :memberId ORDER BY created_at DESC") + Flux findByMemberId(Long memberId); + + /** + * 按会员卡记录ID查询所有流水 + */ + @Query("SELECT * FROM member_card_transactions WHERE member_card_record_id = :recordId ORDER BY created_at DESC") + Flux findByRecordId(Long recordId); + + /** + * 统计某卡种的总扣次数 + */ + @Query("SELECT COALESCE(SUM(change_amount), 0) FROM member_card_transactions " + + "WHERE member_card_id = :memberCardId AND operation_type = 'DEDUCT' " + + "AND created_at BETWEEN :startTime AND :endTime") + Mono sumDeductCountByCardId(Long memberCardId, + LocalDateTime startTime, + LocalDateTime endTime); + + /** + * 统计某时间段的续费总金额 + */ + @Query("SELECT COALESCE(SUM(change_balance), 0) FROM member_card_transactions " + + "WHERE operation_type = 'RENEW' AND created_at BETWEEN :startTime AND :endTime") + Mono sumRenewAmountByTimeRange(LocalDateTime startTime, LocalDateTime endTime); + + /** + * 统计某会员的购卡总金额 + */ + @Query("SELECT COALESCE(SUM(change_balance), 0) FROM member_card_transactions " + + "WHERE member_id = :memberId AND operation_type = 'PURCHASE' " + + "AND created_at BETWEEN :startTime AND :endTime") + Mono sumPurchaseAmountByMemberId(Long memberId, LocalDateTime startTime, LocalDateTime endTime); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/RefundApplicationRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/RefundApplicationRepository.java new file mode 100644 index 0000000..5c4e4c1 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/RefundApplicationRepository.java @@ -0,0 +1,48 @@ +package cn.novalon.gym.manage.member.repository; + +import cn.novalon.gym.manage.member.entity.RefundApplication; +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; + +/** + * 退款申请 Repository + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Repository +public interface RefundApplicationRepository extends R2dbcRepository { + + /** + * 根据会员卡记录ID查询退款申请 + */ + @Query("SELECT * FROM refund_application WHERE record_id = :recordId AND deleted_at IS NULL LIMIT 1") + Mono findByRecordId(Long recordId); + + /** + * 根据会员ID查询退款申请列表 + */ + @Query("SELECT * FROM refund_application WHERE member_id = :memberId AND deleted_at IS NULL ORDER BY created_at DESC") + Flux findByMemberId(Long memberId); + + /** + * 根据状态查询退款申请列表 + */ + @Query("SELECT * FROM refund_application WHERE status = :status AND deleted_at IS NULL ORDER BY created_at DESC") + Flux findByStatus(String status); + + /** + * 审核退款申请 + */ + @Query("UPDATE refund_application SET status = :status, auditor_id = :auditorId, audit_time = NOW(), audit_remark = :auditRemark, updated_at = NOW() WHERE id = :id") + Mono approve(Long id, String status, Long auditorId, String auditRemark); + + /** + * 逻辑删除退款申请 + */ + @Query("UPDATE refund_application SET deleted_at = NOW() WHERE id = :id") + Mono logicalDelete(Long id); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/WechatUserRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/WechatUserRepository.java new file mode 100644 index 0000000..a0afe0b --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/WechatUserRepository.java @@ -0,0 +1,29 @@ +package cn.novalon.gym.manage.member.repository; + +import cn.novalon.gym.manage.member.entity.WechatUser; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +/** + * 微信用户Repository + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Repository +public interface WechatUserRepository extends R2dbcRepository { + + // 通过UnionID查询微信用户 + Mono findByUnionId(String unionId); + + // 通过小程序OpenID查询微信用户 + Mono findByMiniappOpenid(String miniappOpenid); + + // 通过服务号OpenID查询微信用户 + Mono findByMpOpenid(String mpOpenid); + + // 通过会员ID查询微信用户 + Mono findByMemberId(Long memberId); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardRecordService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardRecordService.java new file mode 100644 index 0000000..52eb9fc --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardRecordService.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.member.service; + +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import org.springframework.data.domain.Pageable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * 会员卡记录服务接口 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public interface IMemberCardRecordService { + + Mono findById(Long id); + + Flux findByMemberId(Long memberId, Pageable pageable); + + Flux findActiveCardsByMemberId(Long memberId); + + Mono insertActiveRecord(MemberCardRecord record); + + Mono deductUsage(Long recordId, Integer deductTimes, Double deductAmount); + + Mono renewCard(Long recordId, Integer addTimes, Double addAmount, java.time.LocalDateTime newExpireTime); + + Mono updateStatus(Long recordId, String status); + + Mono validateCountCard(Long recordId, Integer requiredTimes); + + Mono validateStoredCard(Long recordId, Double requiredAmount); + + Flux findExpiredCards(); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardService.java new file mode 100644 index 0000000..14517b4 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardService.java @@ -0,0 +1,44 @@ +package cn.novalon.gym.manage.member.service; + +import cn.novalon.gym.manage.member.entity.MemberCard; +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import org.springframework.data.domain.Pageable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * 会员卡服务接口 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public interface IMemberCardService { + + Mono findByMemberCardIdAndDeletedAtIsNull(Long memberCardId); + + Flux findWithConditions(Integer status, String name, String type, + Double minPrice, Double maxPrice, Pageable pageable); + + Mono countWithConditions(Integer status, String name, String type, + Double minPrice, Double maxPrice); + + Flux findByMemberCardStatusAndDeletedAtIsNull(Integer status, Pageable pageable); + + Mono existsPurchasedRecord(Long memberCardId); + + Mono logicalDelete(Long memberCardId); + + Flux findActiveCards(Integer status); + + Mono save(MemberCard entity); + + Mono purchaseCard(Long memberId, Long memberCardId, Long sourceOrderId); + + Mono renewCard(Long recordId, Integer addTimes, Double addAmount, Integer addDays, Long sourceOrderId); + + Mono useCard(Long recordId, Integer deductTimes, Double deductAmount); + + Mono refundCard(Long recordId); + + Mono processExpiredCards(); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardTransactionService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardTransactionService.java new file mode 100644 index 0000000..fd7ac94 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IMemberCardTransactionService.java @@ -0,0 +1,93 @@ +package cn.novalon.gym.manage.member.service; + +import cn.novalon.gym.manage.member.entity.MemberCardTransaction; +import cn.novalon.gym.manage.member.enums.TransactionType; +import org.springframework.data.domain.Pageable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** + * 会员卡交易流水服务接口 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public interface IMemberCardTransactionService { + + /** + * 记录每一次变动 + * @param transaction 流水记录 + * @return 插入的流水记录 + */ + Mono insertTransaction(MemberCardTransaction transaction); + + /** + * 会员端"使用记录" + * @param memberId 会员ID + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param pageable 分页参数 + * @return 流水记录列表 + */ + Flux findByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, + LocalDateTime endTime, Pageable pageable); + + /** + * 后台"使用记录查询" + * @param memberId 会员ID + * @param memberCardId 会员卡ID + * @param operationType 操作类型 + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param pageable 分页参数 + * @return 流水记录列表 + */ + Flux findWithConditions(Long memberId, Long memberCardId, + TransactionType operationType, + LocalDateTime startTime, LocalDateTime endTime, + Pageable pageable); + + /** + * 统计符合条件的流水总数 + */ + Mono countWithConditions(Long memberId, Long memberCardId, + TransactionType operationType, + LocalDateTime startTime, LocalDateTime endTime); + + /** + * 按会员卡ID查询所有流水记录 + */ + Flux findByMemberCardId(Long memberCardId); + + /** + * 数据统计 - 统计某卡种的总扣次数 + */ + Mono sumDeductCountByCardId(Long memberCardId, LocalDateTime startTime, LocalDateTime endTime); + + /** + * 数据统计 - 统计某时间段的续费总金额 + */ + Mono sumRenewAmountByTimeRange(LocalDateTime startTime, LocalDateTime endTime); + + /** + * 数据统计 - 统计某会员的购卡总金额 + */ + Mono sumPurchaseAmountByMemberId(Long memberId, LocalDateTime startTime, LocalDateTime endTime); + + /** + * 创建交易记录 + */ + Mono createTransaction(MemberCardTransaction transaction); + + /** + * 查询会员的交易记录 + */ + Flux findByMemberId(Long memberId); + + /** + * 查询会员卡记录的交易历史 + */ + Flux findByRecordId(Long recordId); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IRefundApplicationService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IRefundApplicationService.java new file mode 100644 index 0000000..0c77335 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/IRefundApplicationService.java @@ -0,0 +1,33 @@ +package cn.novalon.gym.manage.member.service; + +import cn.novalon.gym.manage.member.entity.RefundApplication; +import reactor.core.publisher.Mono; + +/** + * 退款申请服务接口 + * + * @author 付嘉 + * @date 2026-05-27 + */ +public interface IRefundApplicationService { + + /** + * 创建退款申请 + */ + Mono create(Long recordId, String reason); + + /** + * 审核退款申请 + */ + Mono approve(Long applicationId, Long auditorId, String remark); + + /** + * 拒绝退款申请 + */ + Mono reject(Long applicationId, Long auditorId, String remark); + + /** + * 根据记录ID查询申请 + */ + Mono findByRecordId(Long recordId); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java new file mode 100644 index 0000000..72e4b1f --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java @@ -0,0 +1,79 @@ +package cn.novalon.gym.manage.member.service; + +import cn.novalon.gym.manage.member.dto.SearchMemberDto; +import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto; +import cn.novalon.gym.manage.member.entity.Member; +import cn.novalon.gym.manage.member.es.entity.MemberES; +import cn.novalon.gym.manage.member.vo.MemberDetailVO; +import cn.novalon.gym.manage.member.vo.MemberInfoVO; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * 会员服务接口 + * + * @author 付嘉 + * @date 2026-05-01 + */ +public interface MemberService { + + /** + * 获取会员信息 + * + * @param memberId 会员ID + * @return 会员信息 + */ + Mono getMemberInfo(Long memberId); + + /** + * 会员更新个人信息 + * + * @param memberId 会员ID + * @param updateDto 更新信息DTO + * @return 更新后的会员信息 + */ + Mono updateMemberInfo(Long memberId, UpdateMemberInfoDto updateDto); + + /** + * 管理端更新会员手机号 + * + * @param memberId 会员ID + * @param phone 明文手机号 + * @return 是否成功 + */ + Mono adminUpdatePhone(Long memberId, String phone); + + /** + * 管理端查询会员(es查询) + * + * @param searchMemberDto 会员信息dto + * @return 会员信息 + */ + Flux searchMember(SearchMemberDto searchMemberDto); + + /** + * 管理端查询所有会员 + * + * @param pageNum 页码 + * @param pageSize 页大小 + * @return 所有会员信息 + */ + Flux findAll(Integer pageNum, Integer pageSize); + + /** + * 前台管理端获取会员详情(含会员卡信息) + * + * @param memberId 会员ID + * @return 会员详情 + */ + Mono getMemberDetail(Long memberId); + + /** + * 前台管理端编辑会员信息 + * + * @param memberId 会员ID + * @param updateDto 更新信息DTO + * @return 更新后的会员详情 + */ + Mono adminUpdateMemberInfo(Long memberId, UpdateMemberInfoDto updateDto); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatApiService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatApiService.java new file mode 100644 index 0000000..3258cb9 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatApiService.java @@ -0,0 +1,48 @@ +package cn.novalon.gym.manage.member.service; + +import reactor.core.publisher.Mono; + +import java.util.Map; + +/** + * 微信API服务接口 + * + * @author 付嘉 + * @date 2026-05-01 + */ +public interface WechatApiService { + + /** + * 小程序- 通过code获取session_key/openid + * + * @param code 小程序登录的code + * @return Mono> session_keyopenidunionid + */ + Mono> jsCode2Session(String code); + + /** + * 获取手机号- 通过code获取手机号 + * + * @param code 小程序登录的code + * @return Mono ܺ手机号 + */ + Mono getPhoneNumber(String code); + + /** + * 获取Access Token + * + * @param appType 应用类型miniapp-小程序mp + * @return Mono access_token + */ + Mono getAccessToken(String appType); + + /** + * 验证签名 + * + * @param signature 微信签名 + * @param timestamp 创建时间 + * @param nonce 随机字符串 + * @return boolean 签名是否有效 + */ + boolean checkSignature(String signature, String timestamp, String nonce); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatAuthService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatAuthService.java new file mode 100644 index 0000000..fc52de9 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatAuthService.java @@ -0,0 +1,31 @@ +package cn.novalon.gym.manage.member.service; + +import cn.novalon.gym.manage.member.dto.WechatLoginDto; +import cn.novalon.gym.manage.member.vo.WechatLoginVO; +import reactor.core.publisher.Mono; + +/** + * 微信授权服务接口 + * + * @author 付嘉 + * @date 2026-05-01 + */ +public interface WechatAuthService { + + /** + * 小程序 + * + * @param request 小程序登录请求 + * @return 小程序登录响应 + */ + Mono miniappLogin(WechatLoginDto request); + + /** + * 手机号 + * + * @param memberId 会员ID + * @param code 微信手机号code + * @return 是否绑定成功 + */ + Mono bindPhone(Long memberId, String code); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatOfficialService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatOfficialService.java new file mode 100644 index 0000000..0a78d6f --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/WechatOfficialService.java @@ -0,0 +1,54 @@ +package cn.novalon.gym.manage.member.service; + +import cn.novalon.gym.manage.member.vo.WechatUserInfoVO; +import reactor.core.publisher.Mono; + +/** + * 微信公众号服务接口 + * + * @author 付嘉 + * @date 2026-05-01 + */ +public interface WechatOfficialService { + + /** + * 处理订阅事件 + * + * @param openId OpenID + * @return Mono + */ + Mono handleSubscribeEvent(String openId); + + /** + * 处理取消订阅事件 + * + * @param openId OpenID + * @return Mono + */ + Mono handleUnsubscribeEvent(String openId); + + /** + * 获取微信用户信息 + * + * @param openId OpenID + * @return Mono 用户信息 + */ + Mono getUserInfo(String openId); + + /** + * 通过 UnionID 关联小程序用户 + * + * @param unionId 微信 UnionID + * @param officialOpenId OpenID + * @return Mono 是否关联成功 + */ + Mono linkByUnionId(String unionId, String officialOpenId); + + /** + * 查询会员订阅状态 + * + * @param memberId 会员ID + * @return Mono true=已订阅false=未订阅 + */ + Mono checkSubscribeStatus(Long memberId); +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardRecordServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardRecordServiceImpl.java new file mode 100644 index 0000000..0cbbd76 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardRecordServiceImpl.java @@ -0,0 +1,124 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; +import cn.novalon.gym.manage.member.service.IMemberCardRecordService; +import cn.novalon.gym.manage.member.util.RedisUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** + * 会员卡记录服务实现 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Service +public class MemberCardRecordServiceImpl implements IMemberCardRecordService { + private final MemberCardRecordRepository memberCardRecordRepository; + private final RedisUtil redisUtil; + + private static final String MEMBER_CARD_RECORD_CACHE_PREFIX = "member:card:record:"; + private static final long CACHE_EXPIRE_SECONDS = 300; + + public MemberCardRecordServiceImpl(MemberCardRecordRepository memberCardRecordRepository, RedisUtil redisUtil) { + this.memberCardRecordRepository = memberCardRecordRepository; + this.redisUtil = redisUtil; + } + + @Override + public Mono findById(Long recordId) { + String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId; + Object cached = redisUtil.get(cacheKey); + if (cached != null && cached instanceof MemberCardRecord) { + log.debug("从缓存获取会员卡记录, recordId: {}", recordId); + return Mono.just((MemberCardRecord) cached); + } + + return memberCardRecordRepository.findById(recordId) + .doOnSuccess(record -> { + if (record != null) { + redisUtil.setWithExpire(cacheKey, record, CACHE_EXPIRE_SECONDS); + } + }); + } + + @Override + public Flux findByMemberId(Long memberId, Pageable pageable) { + return memberCardRecordRepository.findByMemberId(memberId, pageable); + } + + @Override + public Flux findActiveCardsByMemberId(Long memberId) { + return memberCardRecordRepository.findActiveCardsByMemberId(memberId); + } + + @Override + public Mono insertActiveRecord(MemberCardRecord record) { + return memberCardRecordRepository.insertActiveRecord( + record.getMemberId(), + record.getMemberCardId(), + record.getExpireTime(), + record.getRemainingTimes(), + record.getRemainingAmount(), + record.getSourceOrderId() + ); + } + + @Override + public Mono deductUsage(Long recordId, Integer deductTimes, Double deductAmount) { + return memberCardRecordRepository.deductUsage(recordId, deductTimes, deductAmount) + .doOnSuccess(updated -> { + if (updated > 0) { + clearRecordCache(recordId); + } + }); + } + + @Override + public Mono renewCard(Long recordId, Integer addTimes, Double addAmount, LocalDateTime newExpireTime) { + return memberCardRecordRepository.renewCard(recordId, addTimes, addAmount, newExpireTime) + .doOnSuccess(updated -> { + if (updated > 0) { + clearRecordCache(recordId); + } + }); + } + + @Override + public Mono updateStatus(Long recordId, String status) { + return memberCardRecordRepository.updateStatus(recordId, status) + .doOnSuccess(updated -> { + if (updated > 0) { + clearRecordCache(recordId); + } + }); + } + + @Override + public Mono validateCountCard(Long recordId, Integer requiredTimes) { + return memberCardRecordRepository.validateCountCard(recordId, requiredTimes); + } + + @Override + public Mono validateStoredCard(Long recordId, Double requiredAmount) { + return memberCardRecordRepository.validateStoredCard(recordId, requiredAmount); + } + + @Override + public Flux findExpiredCards() { + return memberCardRecordRepository.findExpiredCards(); + } + + private void clearRecordCache(Long recordId) { + String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId; + redisUtil.delete(cacheKey); + log.debug("清除会员卡记录缓存, recordId: {}", recordId); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardServiceImpl.java new file mode 100644 index 0000000..87e345b --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardServiceImpl.java @@ -0,0 +1,362 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.member.entity.MemberCard; +import cn.novalon.gym.manage.member.entity.MemberCardRecord; +import cn.novalon.gym.manage.member.entity.MemberCardTransaction; +import cn.novalon.gym.manage.member.enums.CardEvent; +import cn.novalon.gym.manage.member.enums.MemberCardRecordStatus; +import cn.novalon.gym.manage.member.enums.MemberCardType; +import cn.novalon.gym.manage.member.enums.TransactionType; +import cn.novalon.gym.manage.member.handler.DistributedLockService; +import cn.novalon.gym.manage.member.handler.ExpirationReminderService; +import cn.novalon.gym.manage.member.handler.MemberCardStateMachine; +import cn.novalon.gym.manage.member.handler.RefundSagaHandler; +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.member.util.RedisUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** + * 会员卡服务实现 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Service +public class MemberCardServiceImpl implements IMemberCardService { + private final MemberCardRepository memberCardRepository; + private final MemberCardRecordRepository recordRepository; + private final IMemberCardTransactionService transactionService; + private final MemberCardStateMachine stateMachine; + private final DistributedLockService distributedLockService; + private final ExpirationReminderService expirationReminderService; + private final RefundSagaHandler refundSagaHandler; + private final RedisUtil redisUtil; + + private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:"; + private static final long CACHE_EXPIRE_SECONDS = 300; + + public MemberCardServiceImpl(MemberCardRepository memberCardRepository, + MemberCardRecordRepository recordRepository, + IMemberCardTransactionService transactionService, + MemberCardStateMachine stateMachine, + DistributedLockService distributedLockService, + ExpirationReminderService expirationReminderService, + RefundSagaHandler refundSagaHandler, + RedisUtil redisUtil) { + this.memberCardRepository = memberCardRepository; + this.recordRepository = recordRepository; + this.transactionService = transactionService; + this.stateMachine = stateMachine; + this.distributedLockService = distributedLockService; + this.expirationReminderService = expirationReminderService; + this.refundSagaHandler = refundSagaHandler; + this.redisUtil = redisUtil; + } + + @Override + public Mono findByMemberCardIdAndDeletedAtIsNull(Long memberCardId) { + String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId; + Object cached = redisUtil.get(cacheKey); + if (cached != null && cached instanceof MemberCard) { + log.debug("从缓存获取会员卡信息, memberCardId: {}", memberCardId); + return Mono.just((MemberCard) cached); + } + + return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(memberCardId) + .doOnSuccess(card -> { + if (card != null) { + redisUtil.setWithExpire(cacheKey, card, CACHE_EXPIRE_SECONDS); + } + }); + } + + @Override + public Flux findWithConditions(Integer status, String name, String type, + Double minPrice, Double maxPrice, Pageable pageable) { + return memberCardRepository.findWithConditions(status, name, type, minPrice, maxPrice, pageable); + } + + @Override + public Mono countWithConditions(Integer status, String name, String type, + Double minPrice, Double maxPrice) { + return memberCardRepository.countWithConditions(status, name, type, minPrice, maxPrice); + } + + @Override + public Flux findByMemberCardStatusAndDeletedAtIsNull(Integer status, Pageable pageable) { + return memberCardRepository.findByMemberCardStatusAndDeletedAtIsNull(status, pageable); + } + + @Override + public Mono existsPurchasedRecord(Long memberCardId) { + return memberCardRepository.existsPurchasedRecord(memberCardId); + } + + @Override + public Mono logicalDelete(Long memberCardId) { + return memberCardRepository.logicalDelete(memberCardId); + } + + @Override + public Flux findActiveCards(Integer status) { + return memberCardRepository.findActiveCards(status); + } + + @Override + public Mono save(MemberCard entity) { + return memberCardRepository.save(entity) + .doOnSuccess(saved -> { + if (saved.getMemberCardId() != null) { + clearCardCache(saved.getMemberCardId()); + } + }); + } + + @Override + public Mono purchaseCard(Long memberId, Long memberCardId, Long sourceOrderId) { + return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(memberCardId) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在"))) + .flatMap(card -> { + if (card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) { + return Mono.error(new RuntimeException("该会员卡已禁用")); + } + + MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType()); + + return distributedLockService.executeWithLock( + memberId.toString(), + cardType.name(), + Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card)) + ); + }) + .flatMap(record -> createTransaction(record, TransactionType.PURCHASE, "购买会员卡") + .thenReturn(record)) + .flatMap(record -> expirationReminderService.scheduleExpirationReminder(record) + .then(Mono.just(record))); + } + + private Mono createCardRecord(Long memberId, Long memberCardId, + Long sourceOrderId, MemberCard card) { + return Mono.defer(() -> { + MemberCardRecord record = MemberCardRecord.builder() + .memberId(memberId) + .memberCardId(memberCardId) + .sourceOrderId(sourceOrderId) + .purchaseTime(LocalDateTime.now()) + .status(MemberCardRecordStatus.ACTIVE) + .build(); + + MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType()); + LocalDateTime now = LocalDateTime.now(); + + switch (cardType) { + case TIME_CARD: + record.setExpireTime(now.plusDays(card.getMemberCardValidityDays())); + record.setRemainingTimes(0); + record.setRemainingAmount(0.0); + break; + case COUNT_CARD: + record.setExpireTime(now.plusDays(card.getMemberCardValidityDays())); + record.setRemainingTimes(card.getMemberCardTotalTimes()); + record.setRemainingAmount(0.0); + break; + case STORED_VALUE_CARD: + record.setExpireTime(now.plusYears(1)); + record.setRemainingTimes(0); + record.setRemainingAmount(card.getMemberCardAmount()); + break; + default: + return Mono.error(new RuntimeException("不支持的会员卡类型")); + } + + return recordRepository.insertActiveRecord( + record.getMemberId(), + record.getMemberCardId(), + record.getExpireTime(), + record.getRemainingTimes(), + record.getRemainingAmount(), + record.getSourceOrderId() + ); + }); + } + + @Override + public Mono renewCard(Long recordId, Integer addTimes, Double addAmount, + Integer addDays, Long sourceOrderId) { + return recordRepository.findById(recordId) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在"))) + .flatMap(originalRecord -> stateMachine.validateTransition(originalRecord, CardEvent.RENEW) + .then(Mono.just(originalRecord))) + .flatMap(originalRecord -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(originalRecord.getMemberCardId()) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在"))) + .flatMap(card -> { + MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType()); + + return distributedLockService.executeWithLock( + originalRecord.getMemberId().toString(), + cardType.name(), + Mono.defer(() -> doRenewCard(originalRecord, card, addTimes, addAmount, addDays)) + ); + })); + } + + private Mono doRenewCard(MemberCardRecord record, MemberCard card, + Integer addTimes, Double addAmount, Integer addDays) { + LocalDateTime now = LocalDateTime.now(); + MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType()); + + switch (cardType) { + case TIME_CARD: + LocalDateTime currentExpire = record.getExpireTime(); + LocalDateTime baseTime = (currentExpire != null && currentExpire.isAfter(now)) ? currentExpire : now; + int daysToAdd = addDays != null ? addDays : card.getMemberCardValidityDays(); + record.setExpireTime(baseTime.plusDays(daysToAdd)); + break; + case COUNT_CARD: + int currentTimes = record.getRemainingTimes() != null ? record.getRemainingTimes() : 0; + int timesToAdd = addTimes != null ? addTimes : card.getMemberCardTotalTimes(); + record.setRemainingTimes(currentTimes + timesToAdd); + if (MemberCardRecordStatus.USED_UP.equals(record.getStatus())) { + record.setStatus(MemberCardRecordStatus.ACTIVE); + } + break; + case STORED_VALUE_CARD: + double currentAmount = record.getRemainingAmount() != null ? record.getRemainingAmount() : 0.0; + double amountToAdd = addAmount != null ? addAmount : card.getMemberCardAmount(); + record.setRemainingAmount(currentAmount + amountToAdd); + if (MemberCardRecordStatus.USED_UP.equals(record.getStatus())) { + record.setStatus(MemberCardRecordStatus.ACTIVE); + } + break; + default: + return Mono.error(new RuntimeException("不支持的会员卡类型")); + } + + return recordRepository.save(record) + .flatMap(updatedRecord -> createTransaction(updatedRecord, TransactionType.RENEW, "续费会员卡") + .then(expirationReminderService.scheduleExpirationReminder(updatedRecord)) + .thenReturn(updatedRecord)); + } + + @Override + public Mono useCard(Long recordId, Integer deductTimes, Double deductAmount) { + return recordRepository.findById(recordId) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在"))) + .flatMap(record -> stateMachine.validateTransition(record, CardEvent.USE) + .then(Mono.just(record))) + .flatMap(record -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId()) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在"))) + .flatMap(card -> { + MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType()); + + return distributedLockService.executeWithLock( + record.getMemberId().toString(), + cardType.name(), + Mono.defer(() -> doUseCard(record, card, deductTimes, deductAmount)) + ); + })); + } + + private Mono doUseCard(MemberCardRecord record, MemberCard card, + Integer deductTimes, Double deductAmount) { + if (!MemberCardRecordStatus.ACTIVE.name().equals(record.getStatus())) { + return Mono.error(new RuntimeException("会员卡状态不正确")); + } + + MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType()); + LocalDateTime now = LocalDateTime.now(); + + switch (cardType) { + case TIME_CARD: + if (record.getExpireTime() != null && record.getExpireTime().isBefore(now)) { + return Mono.error(new RuntimeException("会员卡已过期")); + } + break; + case COUNT_CARD: + int currentTimes = record.getRemainingTimes() != null ? record.getRemainingTimes() : 0; + int timesToDeduct = deductTimes != null ? deductTimes : 1; + if (currentTimes < timesToDeduct) { + return Mono.error(new RuntimeException("剩余次数不足")); + } + record.setRemainingTimes(currentTimes - timesToDeduct); + if (record.getRemainingTimes() == 0) { + record.setStatus(MemberCardRecordStatus.USED_UP); + } + break; + case STORED_VALUE_CARD: + double currentAmount = record.getRemainingAmount() != null ? record.getRemainingAmount() : 0.0; + double amountToDeduct = deductAmount != null ? deductAmount : 0.0; + if (currentAmount < amountToDeduct) { + return Mono.error(new RuntimeException("余额不足")); + } + record.setRemainingAmount(currentAmount - amountToDeduct); + if (record.getRemainingAmount() == 0) { + record.setStatus(MemberCardRecordStatus.USED_UP); + } + break; + default: + return Mono.error(new RuntimeException("不支持的会员卡类型")); + } + + return recordRepository.save(record) + .flatMap(updatedRecord -> createTransaction(updatedRecord, TransactionType.DEDUCT, "使用会员卡") + .thenReturn(updatedRecord)); + } + + @Override + public Mono refundCard(Long recordId) { + return recordRepository.findById(recordId) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在"))) + .flatMap(record -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId()) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在"))) + .flatMap(card -> { + MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType()); + return distributedLockService.executeWithLock( + record.getMemberId().toString(), + cardType.name(), + refundSagaHandler.executeRefund(recordId) + ); + })); + } + + @Override + public Mono processExpiredCards() { + return recordRepository.findExpiredCards() + .flatMap(record -> stateMachine.transition(record.getStatus(), CardEvent.EXPIRE) + .flatMap(newState -> recordRepository.updateStatus( + record.getMemberCardRecordId(), newState.name()))) + .reduce(0, (count, result) -> count + 1) + .doOnSuccess(count -> log.info("处理过期会员卡完成,共处理{}条", count)); + } + + private Mono createTransaction(MemberCardRecord record, TransactionType action, String remark) { + MemberCardTransaction transaction = MemberCardTransaction.builder() + .memberId(record.getMemberId()) + .memberCardId(record.getMemberCardId()) + .operationType(action.name()) + .changeAmount(record.getRemainingTimes()) + .changeBalance(record.getRemainingAmount()) + .afterRemainingCount(record.getRemainingTimes()) + .afterRemainingBalance(record.getRemainingAmount()) + .remark(remark) + .build(); + + return transactionService.createTransaction(transaction); + } + + private void clearCardCache(Long memberCardId) { + String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId; + redisUtil.delete(cacheKey); + log.debug("清除会员卡缓存, memberCardId: {}", memberCardId); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardTransactionServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardTransactionServiceImpl.java new file mode 100644 index 0000000..987ed5a --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardTransactionServiceImpl.java @@ -0,0 +1,109 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.member.entity.MemberCardTransaction; +import cn.novalon.gym.manage.member.enums.TransactionType; +import cn.novalon.gym.manage.member.repository.MemberCardTransactionRepository; +import cn.novalon.gym.manage.member.service.IMemberCardTransactionService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** + * 会员卡交易流水服务实现 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Service +public class MemberCardTransactionServiceImpl implements IMemberCardTransactionService { + private final MemberCardTransactionRepository transactionRepository; + + public MemberCardTransactionServiceImpl(MemberCardTransactionRepository transactionRepository) { + this.transactionRepository = transactionRepository; + } + + @Override + public Mono insertTransaction(MemberCardTransaction transaction) { + return transactionRepository.insertTransaction( + transaction.getMemberCardRecordId(), + transaction.getMemberCardId(), + transaction.getMemberId(), + transaction.getOperationType(), + transaction.getChangeAmount(), + transaction.getChangeBalance(), + transaction.getAfterRemainingCount(), + transaction.getAfterRemainingBalance(), + transaction.getRelatedBizType(), + transaction.getSourceOrderId(), + transaction.getRemark() + ); + } + + @Override + public Flux findByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, + LocalDateTime endTime, Pageable pageable) { + return transactionRepository.findByMemberIdAndTimeRange(memberId, startTime, endTime, pageable); + } + + @Override + public Flux findWithConditions(Long memberId, Long memberCardId, + TransactionType operationType, + LocalDateTime startTime, LocalDateTime endTime, + Pageable pageable) { + return transactionRepository.findWithConditions(memberId, memberCardId, + operationType != null ? operationType.name() : null, + startTime, endTime, pageable); + } + + @Override + public Mono countWithConditions(Long memberId, Long memberCardId, + TransactionType operationType, + LocalDateTime startTime, LocalDateTime endTime) { + return transactionRepository.countWithConditions(memberId, memberCardId, + operationType != null ? operationType.name() : null, + startTime, endTime); + } + + @Override + public Flux findByMemberCardId(Long memberCardId) { + return transactionRepository.findByMemberCardId(memberCardId); + } + + @Override + public Mono sumDeductCountByCardId(Long memberCardId, LocalDateTime startTime, LocalDateTime endTime) { + return transactionRepository.sumDeductCountByCardId(memberCardId, startTime, endTime); + } + + @Override + public Mono sumRenewAmountByTimeRange(LocalDateTime startTime, LocalDateTime endTime) { + return transactionRepository.sumRenewAmountByTimeRange(startTime, endTime); + } + + @Override + public Mono sumPurchaseAmountByMemberId(Long memberId, LocalDateTime startTime, LocalDateTime endTime) { + return transactionRepository.sumPurchaseAmountByMemberId(memberId, startTime, endTime); + } + + @Override + public Mono createTransaction(MemberCardTransaction transaction) { + return transactionRepository.save(transaction) + .then() + .doOnSuccess(v -> log.info("创建会员卡交易记录: memberId={}, cardId={}, type={}", + transaction.getMemberId(), transaction.getMemberCardId(), transaction.getOperationType())); + } + + @Override + public Flux findByMemberId(Long memberId) { + return transactionRepository.findByMemberId(memberId); + } + + @Override + public Flux findByRecordId(Long recordId) { + return transactionRepository.findByRecordId(recordId); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java new file mode 100644 index 0000000..cecdaed --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java @@ -0,0 +1,349 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.common.exception.ConflictException; +import cn.novalon.gym.manage.common.exception.ErrorCode; +import cn.novalon.gym.manage.common.exception.NotFoundException; +import cn.novalon.gym.manage.common.exception.SystemException; +import cn.novalon.gym.manage.common.util.HtmlEscapeUtil; +import cn.novalon.gym.manage.member.dto.SearchMemberDto; +import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto; +import cn.novalon.gym.manage.member.entity.Member; +import cn.novalon.gym.manage.member.enums.GenderEnum; +import cn.novalon.gym.manage.member.enums.MemberCardType; +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.service.MemberService; +import cn.novalon.gym.manage.member.util.AesUtil; +import cn.novalon.gym.manage.member.util.BeanConvertUtil; +import cn.novalon.gym.manage.member.util.EsSyncUtils; +import cn.novalon.gym.manage.member.util.RedisUtil; +import cn.novalon.gym.manage.member.vo.MemberCardInfoVO; +import cn.novalon.gym.manage.member.vo.MemberDetailVO; +import cn.novalon.gym.manage.member.vo.MemberInfoVO; +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.IndexResponse; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpHost; +import org.elasticsearch.client.RestClient; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +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.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 会员服务实现 + * + * @author 付嘉 + * @date 2026-05-01 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberServiceImpl implements MemberService { + + private final IMemberRepository memberRepository; + private final MemberESRepository memberESRepository; + private final EsSyncUtils esSyncUtils; + private final RedisUtil redisUtil; + + private EsSyncUtils.EntitySyncer memberSyncer; + + private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:"; + private static final String MEMBER_DETAIL_CACHE_PREFIX = "member:detail:"; + private static final long CACHE_EXPIRE_SECONDS = 300; + + @PostConstruct + public void init() { + this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository); + } + + @Override + public Mono 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, "会员不存在"); + })); + }); + } + + @Override + public Mono updateMemberInfo(Long memberId, UpdateMemberInfoDto updateDto) { + log.info("会员更新个人信息, memberId: {}", memberId); + + return memberRepository.findById(memberId) + .flatMap(member -> { + if (updateDto.getNickname() != null) { + member.setNickname(HtmlEscapeUtil.escape(updateDto.getNickname())); + } + if (updateDto.getGender() != null) { + member.setGender(updateDto.getGender().getCode()); + } + if (updateDto.getBirthday() != null) { + member.setBirthday(updateDto.getBirthday()); + } + if (updateDto.getAvatar() != null) { + member.setAvatar(updateDto.getAvatar()); + } + if (updateDto.getAddress() != null) { + member.setAddress(HtmlEscapeUtil.escape(updateDto.getAddress())); + } + + return memberRepository.save(member); + }) + .flatMap(savedMember -> { + memberSyncer.sync(savedMember); + return clearMemberCache(memberId) + .then(Mono.just(savedMember)); + }) + .map(savedMember -> { + log.info("会员信息更新成功, memberId: {}", savedMember.getId()); + return buildMemberInfoResponse(savedMember); + }) + .switchIfEmpty(Mono.error(() -> { + log.error("会员不存在: memberId={}", memberId); + throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在"); + })); + } + + private MemberInfoVO buildMemberInfoResponse(Member member) { + String phone = member.getPhone(); + String maskedPhone = phone != null ? phone.replace(phone.substring(3, 7), "****") : null; + + GenderEnum genderEnum = GenderEnum.fromCode(member.getGender()); + + return MemberInfoVO.builder() + .id(member.getId()) + .nickname(member.getNickname()) + .phone(maskedPhone) + .gender(genderEnum) + .genderDesc(genderEnum.getDesc()) + .birthday(member.getBirthday()) + .avatar(member.getAvatar()) + .hasPhone(phone != null) + .isSubscribed(member.getSubscribed() != null && member.getSubscribed()) + .build(); + } + + @Override + public Mono adminUpdatePhone(Long memberId, String phone) { + log.info("管理端录入手机号, memberId: {}, phone: {}", memberId, phone); + + String encryptedPhone; + try { + encryptedPhone = AesUtil.encrypt(phone); + log.info("手机号加密成功"); + } catch (Exception e) { + log.error("手机号加密失败", e); + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "手机号加密失败: " + e.getMessage()); + } + + return memberRepository.findByPhone(encryptedPhone) + .flatMap(existingMember -> { + if (existingMember.getId().equals(memberId)) { + log.warn("手机号已是当前用户的: memberId={}", memberId); + throw new ConflictException(ErrorCode.CONFLICT_DUPLICATE_USER, "重复绑定"); + } else { + log.warn("手机号已被其他用户绑定: memberId={}, existingMemberId={}", + memberId, existingMember.getId()); + throw new ConflictException(ErrorCode.CONFLICT_DUPLICATE_USER, "该手机号已被其他会员绑定"); + } + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("手机号未被占用,可以绑定"); + return updateMemberPhone(memberId, encryptedPhone); + })); + } + + @Override + public Flux searchMember(SearchMemberDto searchMemberDto) { + log.info("搜索会员, searchValue: {}, pageNum: {}, pageSize: {}", + searchMemberDto.getSearchValue(), + searchMemberDto.getPageNum(), + searchMemberDto.getPageSize()); + + String searchValue = searchMemberDto.getSearchValue(); + + if (searchValue != null && searchValue.matches("^1[3-9]\\d{9}$")) { + log.debug("搜索值为手机号格式,进行加密处理"); + searchValue = AesUtil.encrypt(searchValue); + } + + Pageable pageable = PageRequest.of( + searchMemberDto.getPageNum() - 1, + searchMemberDto.getPageSize() + ); + + if (searchValue == null) { + log.warn("搜索值为空,返回空结果"); + return Flux.empty(); + } + + return memberESRepository.findByMemberNoOrPhoneOrNicknameContaining( + searchValue, + searchValue, + searchValue, + pageable + ); + } + + @Override + public Flux findAll(Integer pageNum, Integer pageSize) { + log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize); + + Pageable pageable = PageRequest.of( + pageNum - 1, + pageSize + ); + + return memberRepository.findAllBy(pageable); + } + + @Override + public Mono getMemberDetail(Long memberId) { + log.info("查询会员详情, memberId: {}", memberId); + + String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId; + + return redisUtil.get(cacheKey, MemberDetailVO.class) + .flatMap(cached -> { + if (cached != null) { + log.debug("从缓存获取会员详情, memberId: {}", memberId); + return Mono.just(cached); + } + return memberRepository.findById(memberId) + .zipWith( + memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) + .collectList(), + (baseInfo, cardList) -> { + MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class); + + GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); + memberDetailVO.setGenderDesc(genderEnum.getDesc()); + + List enrichedCards = cardList.stream() + .peek(vo -> { + if (vo.getMemberCardType() != null) { + try { + MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType()); + vo.setMemberCardTypeDesc(cardType.getDesc()); + } catch (IllegalArgumentException e) { + vo.setMemberCardTypeDesc(vo.getMemberCardType()); + } + } + if (vo.getMemberCardStatus() != null) { + vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架"); + } + }) + .collect(Collectors.toList()); + memberDetailVO.setMemberCards(enrichedCards); + + long activeCount = enrichedCards.stream() + .filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) + .count(); + memberDetailVO.setActiveCardCount((int) activeCount); + memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount); + + return memberDetailVO; + } + ) + .flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) + .then(Mono.just(vo))); + }); + } + + + @Override + public Mono adminUpdateMemberInfo(Long memberId, UpdateMemberInfoDto updateDto) { + log.info("前台管理端编辑会员信息, memberId: {}", memberId); + + return memberRepository.findById(memberId) + .switchIfEmpty(Mono.error(() -> { + log.error("会员不存在: memberId={}", memberId); + throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在"); + })) + .flatMap(member -> { + if (updateDto.getNickname() != null) { + member.setNickname(HtmlEscapeUtil.escape(updateDto.getNickname())); + } + if (updateDto.getGender() != null) { + member.setGender(updateDto.getGender().getCode()); + } + if (updateDto.getBirthday() != null) { + member.setBirthday(updateDto.getBirthday()); + } + if (updateDto.getAvatar() != null) { + member.setAvatar(updateDto.getAvatar()); + } + if (updateDto.getAddress() != null) { + member.setAddress(HtmlEscapeUtil.escape(updateDto.getAddress())); + } + + return memberRepository.save(member); + }) + .flatMap(savedMember -> { + memberSyncer.sync(savedMember); + return clearMemberCache(memberId) + .then(Mono.just(true)); + }) + .onErrorResume(e -> { + log.error("编辑会员信息失败, memberId: {}, error: {}", memberId, e.getMessage(), e); + return Mono.just(false); + }); + } + + private Mono updateMemberPhone(Long memberId, String encryptedPhone) { + return memberRepository.findById(memberId) + .flatMap(member -> { + member.setPhone(encryptedPhone); + member.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(member) + .flatMap(savedMember -> { + memberSyncer.sync(savedMember); + return clearMemberCache(memberId) + .then(Mono.just(savedMember)); + }) + .map(savedMember -> { + log.info("手机号录入成功, memberId: {}", savedMember.getId()); + return true; + }); + }) + .switchIfEmpty(Mono.error(() -> { + log.error("会员不存在: memberId={}", memberId); + throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在"); + })); + } + + private Mono clearMemberCache(Long memberId) { + String infoCacheKey = MEMBER_INFO_CACHE_PREFIX + memberId; + String detailCacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId; + return redisUtil.delete(infoCacheKey) + .then(redisUtil.delete(detailCacheKey)) + .doOnSuccess(result -> log.debug("清除会员缓存, memberId: {}", memberId)); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/RefundApplicationServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/RefundApplicationServiceImpl.java new file mode 100644 index 0000000..0e9da72 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/RefundApplicationServiceImpl.java @@ -0,0 +1,125 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.common.util.HtmlEscapeUtil; +import cn.novalon.gym.manage.member.entity.RefundApplication; +import cn.novalon.gym.manage.member.enums.RefundStatus; +import cn.novalon.gym.manage.member.repository.RefundApplicationRepository; +import cn.novalon.gym.manage.member.service.IRefundApplicationService; +import cn.novalon.gym.manage.member.util.RedisUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** + * 退款申请服务实现 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Slf4j +@Service +public class RefundApplicationServiceImpl implements IRefundApplicationService { + + private final RefundApplicationRepository refundApplicationRepository; + private final RedisUtil redisUtil; + + private static final String REFUND_APPLICATION_CACHE_PREFIX = "member:refund:"; + private static final long CACHE_EXPIRE_SECONDS = 300; + + public RefundApplicationServiceImpl(RefundApplicationRepository refundApplicationRepository, RedisUtil redisUtil) { + this.refundApplicationRepository = refundApplicationRepository; + this.redisUtil = redisUtil; + } + + @Override + public Mono create(Long recordId, String reason) { + return refundApplicationRepository.findByRecordId(recordId) + .flatMap(existing -> { + if (existing != null) { + return Mono.error(new RuntimeException("该会员卡记录已有退款申请")); + } + return Mono.empty(); + }) + .then(Mono.defer(() -> { + RefundApplication application = RefundApplication.builder() + .recordId(recordId) + .reason(HtmlEscapeUtil.escape(reason)) + .status(RefundStatus.PENDING) + .applyTime(LocalDateTime.now()) + .build(); + + return refundApplicationRepository.save(application) + .doOnSuccess(app -> { + log.info("创建退款申请成功: applicationId={}, recordId={}", app.getId(), recordId); + clearRefundCache(recordId); + }); + })); + } + + @Override + public Mono approve(Long applicationId, Long auditorId, String remark) { + return refundApplicationRepository.findById(applicationId) + .switchIfEmpty(Mono.error(new RuntimeException("退款申请不存在"))) + .flatMap(application -> { + if (application.getStatus() != RefundStatus.PENDING) { + return Mono.error(new RuntimeException("退款申请状态不正确,当前状态: " + application.getStatus())); + } + + return refundApplicationRepository.approve(applicationId, "APPROVED", auditorId, HtmlEscapeUtil.escape(remark)) + .flatMap(updatedRows -> { + if (updatedRows == 0) { + return Mono.error(new RuntimeException("批准退款申请失败")); + } + clearRefundCache(application.getRecordId()); + log.info("批准退款申请成功: applicationId={}, auditorId={}", applicationId, auditorId); + return refundApplicationRepository.findById(applicationId); + }); + }); + } + + @Override + public Mono reject(Long applicationId, Long auditorId, String remark) { + return refundApplicationRepository.findById(applicationId) + .switchIfEmpty(Mono.error(new RuntimeException("退款申请不存在"))) + .flatMap(application -> { + if (application.getStatus() != RefundStatus.PENDING) { + return Mono.error(new RuntimeException("退款申请状态不正确,当前状态: " + application.getStatus())); + } + + return refundApplicationRepository.approve(applicationId, "REJECTED", auditorId, HtmlEscapeUtil.escape(remark)) + .flatMap(updatedRows -> { + if (updatedRows == 0) { + return Mono.error(new RuntimeException("拒绝退款申请失败")); + } + clearRefundCache(application.getRecordId()); + log.info("拒绝退款申请成功: applicationId={}, auditorId={}", applicationId, auditorId); + return refundApplicationRepository.findById(applicationId); + }); + }); + } + + @Override + public Mono findByRecordId(Long recordId) { + String cacheKey = REFUND_APPLICATION_CACHE_PREFIX + recordId; + Object cached = redisUtil.get(cacheKey); + if (cached != null && cached instanceof RefundApplication) { + log.debug("从缓存获取退款申请, recordId: {}", recordId); + return Mono.just((RefundApplication) cached); + } + + return refundApplicationRepository.findByRecordId(recordId) + .doOnSuccess(application -> { + if (application != null) { + redisUtil.setWithExpire(cacheKey, application, CACHE_EXPIRE_SECONDS); + } + }); + } + + private void clearRefundCache(Long recordId) { + String cacheKey = REFUND_APPLICATION_CACHE_PREFIX + recordId; + redisUtil.delete(cacheKey); + log.debug("清除退款申请缓存, recordId: {}", recordId); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatApiServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatApiServiceImpl.java new file mode 100644 index 0000000..aac7753 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatApiServiceImpl.java @@ -0,0 +1,237 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.common.exception.ErrorCode; +import cn.novalon.gym.manage.common.exception.SystemException; +import cn.novalon.gym.manage.member.config.WechatProperties; +import cn.novalon.gym.manage.member.service.WechatApiService; +import cn.novalon.gym.manage.member.util.RedisUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** + * 微信API服务实现 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Service +@RequiredArgsConstructor +public class WechatApiServiceImpl implements WechatApiService { + + private final WechatProperties wechatProperties; + private final RedisUtil redisUtil; + + private static final String ACCESS_TOKEN_CACHE_PREFIX = "wechat:access_token:"; + private static final long ACCESS_TOKEN_EXPIRE_SECONDS = 7000; // 比官方过期时间短100秒 + + private final WebClient webClient = WebClient.builder() + .baseUrl("https://api.weixin.qq.com") + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) + .build(); + + @Override + public Mono> jsCode2Session(String code) { + log.info("微信jsCode2Session API"); + log.info("信息 - AppID: {}, AppSecret {}", + wechatProperties.getMiniapp().getAppId(), + wechatProperties.getMiniapp().getAppSecret() != null ? + wechatProperties.getMiniapp().getAppSecret().substring(0, Math.min(4, wechatProperties.getMiniapp().getAppSecret().length())) + "***" : "null"); + log.info(" - code: {}", code); + + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/sns/jscode2session") + .queryParam("appid", wechatProperties.getMiniapp().getAppId()) + .queryParam("secret", wechatProperties.getMiniapp().getAppSecret()) + .queryParam("js_code", code) + .queryParam("grant_type", "authorization_code") + .build()) + .retrieve() + .bodyToMono(String.class) + .map(responseBody -> { + log.info("微信API响应: {}", responseBody); + + Map response; + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + response = mapper.readValue(responseBody, Map.class); + } catch (Exception e) { + log.error("微信API响应失败", e); + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "微信API响应失败: " + e.getMessage()); + } + + Map result = new HashMap<>(); + + if (response.containsKey("errcode")) { + Integer errcode = (Integer) response.get("errcode"); + String errmsg = (String) response.get("errmsg"); + log.error("微信API失败, errcode: {}, errmsg: {}", errcode, errmsg); + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "微信API失败 [" + errcode + "]: " + errmsg); + } + + result.put("session_key", (String) response.get("session_key")); + result.put("openid", (String) response.get("openid")); + result.put("unionid", (String) response.get("unionid")); + + log.info("微信API响应成功, openid: {}, unionid: {}", + result.get("openid"), result.get("unionid")); + return result; + }) + .onErrorResume(e -> { + log.error("微信API响应异常 - URL: https://api.weixin.qq.com/sns/jscode2session"); + log.error("异常: {}", e.getClass().getName()); + log.error("异常信息: {}", e.getMessage()); + if (e.getCause() != null) { + log.error("异常原因: {}", e.getCause().getMessage()); + } + if (e instanceof SystemException) { + return Mono.error(e); + } + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "微信API响应异常 " + e.getMessage()); + }); + } + + @Override + public Mono getPhoneNumber(String code) { + log.debug("微信getPhoneNumber API, code: {}", code); + + return getAccessToken("miniapp") + .flatMap(accessToken -> { + + Map requestBody = new HashMap<>(); + requestBody.put("code", code); + + return webClient.post() + .uri(uriBuilder -> uriBuilder + .path("/wxa/business/getuserphonenumber") + .queryParam("access_token", accessToken) + .build()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(Map.class) + .map(response -> { + if (response.containsKey("errcode") && + (Integer) response.get("errcode") == 0) { + + Map phoneInfo = + (Map) response.get("phone_info"); + String phoneNumber = (String) phoneInfo.get("purePhoneNumber"); + + log.info("获取手机号成功{}", phoneNumber); + return phoneNumber; + } else { + String errmsg = (String) response.get("errmsg"); + log.error("获取手机号失败 {}", errmsg); + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "获取手机号失败 " + errmsg); + } + }); + }) + .onErrorResume(e -> { + log.error("获取手机号失败", e); + if (e instanceof SystemException) { + return Mono.error(e); + } + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "获取手机号失败 " + e.getMessage()); + }); + } + + @Override + public Mono getAccessToken(String appType) { + log.debug("获取access_token, appType: {}", appType); + + String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + appType; + + return redisUtil.get(cacheKey, String.class) + .flatMap(cachedToken -> { + if (cachedToken != null) { + log.debug("从缓存获取access_token, appType: {}", appType); + return Mono.just(cachedToken); + } + + String appId, appSecret; + if ("miniapp".equals(appType)) { + appId = wechatProperties.getMiniapp().getAppId(); + appSecret = wechatProperties.getMiniapp().getAppSecret(); + } else { + appId = wechatProperties.getMp().getAppId(); + appSecret = wechatProperties.getMp().getAppSecret(); + } + + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/cgi-bin/token") + .queryParam("grant_type", "client_credential") + .queryParam("appid", appId) + .queryParam("secret", appSecret) + .build()) + .retrieve() + .bodyToMono(Map.class) + .flatMap(response -> { + if (response.containsKey("access_token")) { + String accessToken = (String) response.get("access_token"); + Integer expiresIn = (Integer) response.get("expires_in"); + log.info("获取access_token成功, expires_in: {}s", expiresIn); + return redisUtil.setWithExpire(cacheKey, accessToken, ACCESS_TOKEN_EXPIRE_SECONDS) + .then(Mono.just(accessToken)); + } else { + String errmsg = (String) response.get("errmsg"); + log.error("获取access_token失败: {}", errmsg); + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "获取access_token失败: " + errmsg); + } + }); + }); + } + + @Override + public boolean checkSignature(String signature, String timestamp, String nonce) { + log.debug("验证微信消息签名, signature: {}, timestamp: {}, nonce: {}", + signature, timestamp, nonce); + + try { + String token = wechatProperties.getMp().getToken(); + + String[] arr = new String[]{token, timestamp, nonce}; + Arrays.sort(arr); + + StringBuilder content = new StringBuilder(); + for (String s : arr) { + content.append(s); + } + + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(content.toString().getBytes(StandardCharsets.UTF_8)); + + StringBuilder hexString = new StringBuilder(); + for (byte b : digest) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + String calculatedSignature = hexString.toString(); + + boolean isValid = calculatedSignature.equals(signature); + log.debug("验证微信消息签名结果: {}", isValid ? "通过" : "失败"); + + return isValid; + } catch (Exception e) { + log.error("验证微信消息签名异常", e); + return false; + } + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatAuthServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatAuthServiceImpl.java new file mode 100644 index 0000000..a462a14 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatAuthServiceImpl.java @@ -0,0 +1,321 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.common.exception.ConflictException; +import cn.novalon.gym.manage.common.exception.ErrorCode; +import cn.novalon.gym.manage.common.exception.NotFoundException; +import cn.novalon.gym.manage.common.exception.SystemException; +import cn.novalon.gym.manage.common.util.HtmlEscapeUtil; +import cn.novalon.gym.manage.member.config.WechatProperties; +import cn.novalon.gym.manage.member.dto.WechatLoginDto; +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.service.WechatApiService; +import cn.novalon.gym.manage.member.service.WechatAuthService; +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.member.util.RedisUtil; +import cn.novalon.gym.manage.member.util.WechatPhoneUtil; +import cn.novalon.gym.manage.member.vo.WechatLoginVO; +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; + +/** + * 微信认证服务实现 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Service +@RequiredArgsConstructor +public class WechatAuthServiceImpl implements WechatAuthService { + + private final WechatApiService wechatApiService; + private final IMemberRepository memberRepository; + private final WechatPhoneUtil wechatPhoneUtil; + private final MemberESRepository memberESRepository; + private final EsSyncUtils esSyncUtils; + private final JwtTokenProvider jwtTokenProvider; + private final RedisUtil redisUtil; + + private EsSyncUtils.EntitySyncer memberSyncer; + + private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:"; + private static final long CACHE_EXPIRE_SECONDS = 300; + + @PostConstruct + public void init() { + this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository); + } + + + @Override + public Mono miniappLogin(WechatLoginDto request) { + log.info("开始小程序登录"); + + return wechatApiService.jsCode2Session(request.getCode()) + .flatMap(sessionData -> { + String openid = sessionData.get("openid"); + String unionId = sessionData.get("unionid"); + String sessionKey = sessionData.get("session_key"); + + log.info("微信 API 返回: openid={}, unionid={}", openid, unionId); + + if (unionId != null && !unionId.isEmpty()) { + return memberRepository.findByUnionId(unionId) + .flatMap(member -> { + log.info("找到会员, memberId: {}", member.getId()); + + if (member.getMiniappOpenId() == null || member.getMiniappOpenId().isEmpty()) { + log.info("用户已有 UnionID,补充小程序 OpenID, memberId: {}", member.getId()); + member.setMiniappOpenId(openid); + member.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(member) + .doOnSuccess(saved -> { + memberSyncer.sync(saved); + clearMemberCache(saved.getId()); + }) + .flatMap(savedMember -> { + WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey); + return Mono.just(response); + }); + } else { + log.info("老用户登录,更新最后登录时间, memberId: {}", member.getId()); + member.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(member) + .doOnSuccess(saved -> { + memberSyncer.sync(saved); + clearMemberCache(saved.getId()); + }) + .flatMap(savedMember -> { + WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey); + return Mono.just(response); + }); + } + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("UnionID 未找到,尝试通过小程序 OpenID 查询, openid: {}", openid); + return memberRepository.findByMiniappOpenId(openid) + .flatMap(member -> { + log.info("找到会员, memberId: {}", member.getId()); + member.setUnionId(unionId); + member.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(member) + .doOnSuccess(saved -> { + memberSyncer.sync(saved); + clearMemberCache(saved.getId()); + }) + .flatMap(savedMember -> { + WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey); + return Mono.just(response); + }); + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("OpenID 也未找到,创建新会员(无 UnionID), openid: {}", openid); + return createNewMember(unionId, openid, sessionKey, request.getPhoneCode()); + })); + })); + } else { + log.warn("微信 API 未返回 UnionID,尝试通过小程序 OpenID 查询, openid: {}", openid); + return memberRepository.findByMiniappOpenId(openid) + .flatMap(member -> { + log.info("找到会员, memberId: {}", member.getId()); + member.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(member) + .doOnSuccess(saved -> { + memberSyncer.sync(saved); + clearMemberCache(saved.getId()); + }) + .flatMap(savedMember -> { + WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey); + return Mono.just(response); + }); + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("OpenID也未找到,创建新会员(无UnionID标识信息)"); + return createNewMember(unionId, openid, sessionKey, request.getPhoneCode()); + })); + } + }) + .onErrorResume(e -> { + log.error("小程序登录失败", e); + if (e instanceof SystemException) { + return Mono.error(e); + } + return Mono.error(new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "登录失败: " + e.getMessage())); + }); + } + + @Override + public Mono bindPhone(Long memberId, String code) { + log.info("开始绑定手机号, memberId: {}", memberId); + + return wechatApiService.getPhoneNumber(code) + .flatMap(phoneNumber -> { + log.info("获取手机号: {}", phoneNumber); + + String encryptedPhone = encryptPhone(phoneNumber); + + return memberRepository.findByPhone(encryptedPhone) + .flatMap(existingMember -> { + if (!existingMember.getId().equals(memberId)) { + log.warn("手机号已被其他会员绑定, currentMemberId={}, existingMemberId={}", memberId, existingMember.getId()); + throw new ConflictException(ErrorCode.CONFLICT_DUPLICATE_USER, "手机号已被其他会员绑定"); + } else { + log.info("更新会员手机号, memberId: {}", memberId); + return updateMemberPhone(memberId, encryptedPhone); + } + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("该手机号未被使用,直接绑定到当前会员, memberId: {}", memberId); + return updateMemberPhone(memberId, encryptedPhone); + })); + }) + .onErrorResume(e -> { + log.error("绑定手机号失败", e); + if (e instanceof SystemException) { + return Mono.error(e); + } + return Mono.error(new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "绑定失败: " + e.getMessage())); + }); + } + + private Mono updateMemberPhone(Long memberId, String encryptedPhone) { + return memberRepository.findById(memberId) + .flatMap(member -> { + member.setPhone(encryptedPhone); + member.setLastLoginAt(LocalDateTime.now()); + return memberRepository.save(member) + .doOnSuccess(saved -> { + memberSyncer.sync(saved); + clearMemberCache(saved.getId()); + }) + .map(savedMember -> { + log.info("更新会员手机号成功, memberId: {}", savedMember.getId()); + return true; + }); + }) + .switchIfEmpty(Mono.error(() -> { + log.error("会员不存在, memberId={}", memberId); + throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在"); + })); + } + + private void clearMemberCache(Long memberId) { + String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId; + redisUtil.delete(cacheKey); + log.debug("清除会员缓存, memberId: {}", memberId); + } + + private String encryptPhone(String phoneNumber) { + try { + String encryptedPhone = AesUtil.encrypt(phoneNumber); + + log.debug("手机号加密成功"); + return encryptedPhone; + } catch (Exception e) { + log.error("手机号加密失败", e); + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "手机号加密失败 " + e.getMessage()); + } + } + + public String decryptPhone(String encryptedPhone) { + try { + + String phoneNumber = AesUtil.decrypt(encryptedPhone); + + log.debug("手机号解密成功"); + return phoneNumber; + } catch (Exception e) { + log.error("手机号解密失败", e); + throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "手机号解密失败 " + e.getMessage()); + } + } + + private Mono createNewMember(String unionId, String openid, String sessionKey, String phoneCode) { + log.info("开始创建新会员, unionId: {}, openid: {}", unionId, openid); + + String memberNo = MemberNoGenerator.generate(); + log.info("生成会员号: {}", memberNo); + + Member member = Member.builder() + .memberNo(memberNo) + .unionId(unionId) + .miniappOpenId(openid) + .lastLoginAt(LocalDateTime.now()) + .build(); + + log.info("用户未注册,创建新会员(仅保存标识信息)"); + + return memberRepository.save(member) + .doOnSuccess(memberSyncer::sync) + .flatMap(savedMember -> { + log.info("保存 Member 成功, id: {}, memberNo: {}", savedMember.getId(), savedMember.getMemberNo()); + if (phoneCode != null && !phoneCode.isEmpty()) { + log.info("检测到 phoneCode,尝试获取手机号"); + return wechatPhoneUtil.getPhoneNumber(phoneCode) + .flatMap(phoneNumber -> { + if (phoneNumber != null && !phoneNumber.isEmpty()) { + log.info("获取到手机号: {}", phoneNumber); + String encryptedPhone = encryptPhone(phoneNumber); + savedMember.setPhone(encryptedPhone); + return memberRepository.save(savedMember) + .doOnSuccess(memberSyncer::sync) + .doOnSuccess(m -> { + log.info("新用户手机号绑定成功"); + }) + .thenReturn(buildLoginResponse(savedMember, true, sessionKey)); + } else { + log.warn("未获取到手机号"); + return Mono.just(buildLoginResponse(savedMember, true, sessionKey)); + } + }); + } else { + return Mono.just(buildLoginResponse(savedMember, true, sessionKey)); + } + }); + } + + private WechatLoginVO buildLoginResponse(Member member, boolean isNewUser, String sessionKey) { + log.debug("构建登录响应, memberId: {}, isNewUser: {}", member.getId(), isNewUser); + + boolean needCompleteInfo = member.getNickname() == null || member.getPhone() == null; + if (needCompleteInfo) { + log.info("用户需要补全信息: nickname={}, phone={}", + member.getNickname() != null ? "已有" : "未设置", + member.getPhone() != null ? "已绑定" : "未绑定"); + } + + List roles = new ArrayList<>(); + String accessToken = jwtTokenProvider.generateToken(String.valueOf(member.getId()), member.getId(), roles); + + log.info("JWT Token 生成成功, memberId: {}", member.getId()); + + int expiresIn = 86400; + + return WechatLoginVO.builder() + .memberId(member.getId()) + .accessToken(accessToken) + .refreshToken(accessToken) + .expiresIn(expiresIn) + .isNewUser(isNewUser) + .needCompleteInfo(needCompleteInfo) + .build(); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatOfficialServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatOfficialServiceImpl.java new file mode 100644 index 0000000..22a2d56 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatOfficialServiceImpl.java @@ -0,0 +1,379 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.common.util.HtmlEscapeUtil; +import cn.novalon.gym.manage.member.config.WechatProperties; +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.service.WechatOfficialService; +import cn.novalon.gym.manage.member.util.EsSyncUtils; +import cn.novalon.gym.manage.member.util.RedisUtil; +import cn.novalon.gym.manage.member.vo.WechatUserInfoVO; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 微信服务号服务实现类 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Service +@RequiredArgsConstructor +public class WechatOfficialServiceImpl implements WechatOfficialService { + + private final IMemberRepository memberRepository; + private final WechatProperties wechatProperties; + private final WebClient webClient; + private final MemberESRepository memberESRepository; + private final ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private final EsSyncUtils esSyncUtils; + private final RedisUtil redisUtil; + + private EsSyncUtils.EntitySyncer memberSyncer; + + private static final String ACCESS_TOKEN_CACHE_PREFIX = "wechat:access_token:"; + private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:"; + private static final long ACCESS_TOKEN_EXPIRE_SECONDS = 7000; + private static final long CACHE_EXPIRE_SECONDS = 300; + + @PostConstruct + public void init() { + this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository); + } + + /** + * 处理关注事件 + */ + @Override + public Mono handleSubscribeEvent(String openId) { + log.info("处理关注事件, openId: {}", openId); + + return getUserInfo(openId) + .flatMap(userInfo -> { + String unionId = userInfo.getUnionid(); + log.info("获取到用户信息 unionId: {}, nickname: {}", unionId, userInfo.getNickname()); + + if (unionId != null && !unionId.isEmpty()) { + return memberRepository.findByUnionId(unionId) + .flatMap(existingMember -> { + log.info("通过UnionID找到已有会员, memberId: {}", existingMember.getId()); + + if (existingMember.getOfficialOpenId() == null || existingMember.getOfficialOpenId().isEmpty()) { + log.info("用户先使用小程序,更新服务号OpenID: {}", openId); + existingMember.setSubscribed(true); + existingMember.setLastLoginAt(LocalDateTime.now()); + existingMember.setOfficialOpenId(openId); + + if (existingMember.getNickname() == null || existingMember.getNickname().isEmpty()) { + existingMember.setNickname(HtmlEscapeUtil.escape(userInfo.getNickname())); + } + + if (existingMember.getAvatar() == null || existingMember.getAvatar().isEmpty()) { + existingMember.setAvatar(HtmlEscapeUtil.escape(userInfo.getHeadimgurl())); + } + + return memberRepository.save(existingMember) + .flatMap(saved -> { + memberSyncer.sync(saved); + return clearMemberCache(saved.getId()) + .then(sendWelcomeMessage(openId)); + }); + } else { + log.info("老用户关注服务号: memberId={}", existingMember.getId()); + existingMember.setSubscribed(true); + existingMember.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(existingMember) + .flatMap(saved -> { + memberSyncer.sync(saved); + return clearMemberCache(saved.getId()) + .then(sendWelcomeMessage(openId)); + }); + } + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("UnionID未找到,降级到服务号OpenID查询: {}", openId); + return memberRepository.findByOfficialOpenId(openId) + .flatMap(existingMember -> { + log.info("通过服务号OpenID找到已有会员,更新UnionID, memberId: {}", existingMember.getId()); + existingMember.setUnionId(unionId); + existingMember.setSubscribed(true); + existingMember.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(existingMember) + .doOnSuccess(saved -> { + memberSyncer.sync(saved); + clearMemberCache(saved.getId()); + }) + .then(sendWelcomeMessage(openId)); + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("OpenID也未找到,创建新用户"); + return createNewMemberFromOfficial(unionId, openId) + .then(sendWelcomeMessage(openId)); + })); + })); + } else { + log.warn("用户没有UnionID,尝试通过服务号OpenID查询"); + return memberRepository.findByOfficialOpenId(openId) + .flatMap(existingMember -> { + log.info("通过服务号OpenID找到已有会员, memberId: {}", existingMember.getId()); + existingMember.setSubscribed(true); + existingMember.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(existingMember) + .doOnSuccess(saved -> { + memberSyncer.sync(saved); + clearMemberCache(saved.getId()); + }) + .then(sendWelcomeMessage(openId)); + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("OpenID也未找到,创建新会员(无UnionID)"); + return createNewMemberFromOfficial(unionId, openId) + .then(sendWelcomeMessage(openId)); + })); + } + }) + .then(); + } + + /** + * 处理取消关注事件 + */ + @Override + public Mono handleUnsubscribeEvent(String openId) { + log.info("处理取消关注事件, openId: {}", openId); + + return memberRepository.findByOfficialOpenId(openId) + .flatMap(member -> { + log.info("找到会员,更新为未关注状态, memberId: {}", member.getId()); + member.setSubscribed(false); + member.setLastLoginAt(LocalDateTime.now()); + return memberRepository.save(member) + .flatMap(saved -> { + memberSyncer.sync(saved); + return clearMemberCache(saved.getId()).then(); + }); + }) + .then() + .switchIfEmpty(Mono.defer(() -> { + log.warn("未找到对应的会员记录, officialOpenId: {}", openId); + return Mono.empty(); + })) + .onErrorResume(e -> { + log.error("处理取消关注事件失败", e); + return Mono.empty(); + }); + } + + /** + * 获取微信用户信息 + */ + @Override + public Mono getUserInfo(String openId) { + log.debug("获取微信用户信息, openId: {}", openId); + + // 获取AccessToken + return getAccessToken() + .flatMap(accessToken -> { + String url = "https://api.weixin.qq.com/cgi-bin/user/info" + + "?access_token=" + accessToken + + "&openid=" + openId + + "&lang=zh_CN"; + + return webClient.get() + .uri(url) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(String.class) + .map(responseJson -> { + log.info("微信API原始响应: {}", responseJson); + try { + WechatUserInfoVO userInfo = objectMapper.readValue(responseJson, WechatUserInfoVO.class); + log.info("解析后的用户信息 - unionId: {}, nickname: {}, openid: {}", + userInfo.getUnionid(), userInfo.getNickname(), userInfo.getOpenid()); + return userInfo; + } catch (Exception e) { + log.error("解析微信用户信息失败", e); + throw new RuntimeException("解析微信用户信息失败", e); + } + }); + }); + } + + /** + * 通过 UnionID 关联小程序用户和服务号用户 + */ + @Override + public Mono linkByUnionId(String unionId, String officialOpenId) { + log.debug("关联小程序用户和服务号用户 unionId: {}, officialOpenId: {}", unionId, officialOpenId); + + return memberRepository.findByUnionId(unionId) + .flatMap(member -> { + member.setSubscribed(true); + member.setLastLoginAt(LocalDateTime.now()); + + if (officialOpenId != null && !officialOpenId.isEmpty()) { + member.setOfficialOpenId(officialOpenId); + } + return memberRepository.save(member) + .flatMap(saved -> { + memberSyncer.sync(saved); + return clearMemberCache(saved.getId()) + .then(Mono.just(saved)); + }) + .map(savedMember -> { + log.info("关联成功, memberId: {}", savedMember.getId()); + return true; + }); + }) + .switchIfEmpty(Mono.defer(() -> { + log.warn("未找到对应的会员记录, unionId: {}", unionId); + return Mono.just(false); + })) + .onErrorResume(e -> { + log.error("关联失败", e); + return Mono.just(false); + }); + } + + /** + * 查询用户关注状态 + */ + @Override + public Mono checkSubscribeStatus(Long memberId) { + log.info("查询用户关注状态, memberId: {}", memberId); + + return memberRepository.findById(memberId) + .map(member -> { + Boolean subscribed = member.getSubscribed(); + boolean result = subscribed != null && subscribed; + log.info("查询用户关注状态结果, memberId: {}, subscribed: {}", memberId, result); + return result; + }) + .defaultIfEmpty(false); + } + + /** + * 从服务号用户信息创建新会员 + */ + private Mono createNewMemberFromOfficial(String unionId, String openId) { + Member member = Member.builder() + .unionId(unionId) + .officialOpenId(openId) + .subscribed(true) + .lastLoginAt(LocalDateTime.now()) + .build(); + + log.info("新用户关注服务号,仅保存标识信息(UnionID和OpenID)"); + return memberRepository.save(member) + .doOnSuccess(memberSyncer::sync) + .doOnSuccess(savedMember -> { + log.info("从服务号创建新会员成功, memberId: {}", savedMember.getId()); + }) + .then(); + } + + /** + * 获取微信AccessToken + */ + private Mono getAccessToken() { + String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + "mp"; + + return redisUtil.get(cacheKey, String.class) + .flatMap(cachedToken -> { + if (cachedToken != null) { + log.debug("从缓存获取服务号access_token"); + return Mono.just(cachedToken); + } + + String appId = wechatProperties.getMp().getAppId(); + String appSecret = wechatProperties.getMp().getAppSecret(); + + String url = "https://api.weixin.qq.com/cgi-bin/token" + + "?grant_type=client_credential" + + "&appid=" + appId + + "&secret=" + appSecret; + + return webClient.get() + .uri(url) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(Map.class) + .flatMap(response -> { + if (response.containsKey("errcode")) { + throw new RuntimeException("获取AccessToken失败: " + response.get("errmsg")); + } + String accessToken = (String) response.get("access_token"); + return redisUtil.setWithExpire(cacheKey, accessToken, ACCESS_TOKEN_EXPIRE_SECONDS) + .then(Mono.just(accessToken)); + }); + }); + } + + /** + * 发送欢迎消息给用户 + */ + private Mono sendWelcomeMessage(String openId) { + log.info("发送欢迎消息给 openId: {}", openId); + + // 真正调用微信 API 发送消息(测试模式和生产模式都执行) + return getAccessToken() + .flatMap(accessToken -> { + String url = "https://api.weixin.qq.com/cgi-bin/message/custom/send" + + "?access_token=" + accessToken; + + // 构建客服消息�? + Map messageBody = new HashMap<>(); + messageBody.put("touser", openId); + messageBody.put("msgtype", "text"); + + Map text = new HashMap<>(); + text.put("content", "欢迎使用"); + messageBody.put("text", text); + + return webClient.post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(messageBody) + .retrieve() + .bodyToMono(Map.class) + .doOnSuccess(response -> { + if (response.containsKey("errcode") && !"0".equals(String.valueOf(response.get("errcode")))) { + log.error("发送欢迎消息失败: {}", response.get("errmsg")); + } else { + log.info("欢迎消息发送成功, openId: {}", openId); + } + }) + .doOnError(error -> log.error("发送欢迎消息异常", error)) + .then(); + }) + .onErrorResume(e -> { + log.error("发送欢迎消息失败, openId: {}", openId, e); + return Mono.empty(); // 即使发送失败也不影响主流程 + }); + } + + private Mono clearMemberCache(Long memberId) { + String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId; + return redisUtil.delete(cacheKey) + .doOnSuccess(result -> log.debug("清除会员缓存, memberId: {}", memberId)); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/AesUtil.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/AesUtil.java new file mode 100644 index 0000000..f5fd4af --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/AesUtil.java @@ -0,0 +1,98 @@ +package cn.novalon.gym.manage.member.util; + +import cn.novalon.gym.manage.member.config.WechatProperties; +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.IndexResponse; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpHost; +import org.checkerframework.checker.units.qual.K; +import org.elasticsearch.client.RestClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * AES加密工具类 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Component +public class AesUtil { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; + + private static String KEY; + private static String IV; + + @Autowired + public void setWechatProperties(WechatProperties props) { + KEY = props.getPhoneEncryption().getSecretKey(); // 从配置类读取 + IV = props.getPhoneEncryption().getIv(); + if(KEY == null || IV == null) throw new RuntimeException("请配置AES密钥和偏移量"); + } + + + /** + * AES 解密 + * + * @param encryptedData 加密数据,Base64编码 + * @return 解密后的字符串 + */ + public static String decrypt(String encryptedData) { + + try { + byte[] dataByte = Base64.getDecoder().decode(encryptedData); + byte[] keyByte = Base64.getDecoder().decode(KEY); + byte[] ivByte = Base64.getDecoder().decode(IV); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM); + IvParameterSpec ivParameterSpec = new IvParameterSpec(ivByte); + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); + + byte[] resultByte = cipher.doFinal(dataByte); + return new String(resultByte, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("AES解密失败", e); + throw new RuntimeException("解密失败", e); + } + } + + /** + * AES 加密 + * + * @param data 原始数据 + * @return Base64编码的加密数据 + */ + public static String encrypt(String data) { + try { + byte[] dataByte = data.getBytes(StandardCharsets.UTF_8); + byte[] keyByte = Base64.getDecoder().decode(KEY); + byte[] ivByte = Base64.getDecoder().decode(IV); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM); + IvParameterSpec ivParameterSpec = new IvParameterSpec(ivByte); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); + + byte[] resultByte = cipher.doFinal(dataByte); + return Base64.getEncoder().encodeToString(resultByte); + } catch (Exception e) { + log.error("AES加密失败", e); + throw new RuntimeException("加密失败", e); + } + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/BeanConvertUtil.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/BeanConvertUtil.java new file mode 100644 index 0000000..19d780e --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/BeanConvertUtil.java @@ -0,0 +1,48 @@ +package cn.novalon.gym.manage.member.util; + +import cn.hutool.core.bean.BeanUtil; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Entity、Domain、VO、DTO转换工具类 + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Component +public class BeanConvertUtil { + + /** + * 单个对象泛型转换 + * @param source 源对象 + * @param targetClass 目标类Class + * @return 转换后的目标对象 + */ + public static T toBean(S source, Class targetClass) { + if (source == null) { + return null; + } + return BeanUtil.copyProperties(source, targetClass); + } + + /** + * 集合批量泛型转换 + * @param sourceList 源对象集合 + * @param targetClass 目标类Class + * @return 转换后的目标对象集合 + */ + public static List toBeanList(List sourceList, Class targetClass) { + if (sourceList == null || sourceList.isEmpty()) { + return List.of(); + } + + List targetList = new ArrayList<>(); + for (S source : sourceList) { + targetList.add(toBean(source, targetClass)); + } + return targetList; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/EsSyncUtils.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/EsSyncUtils.java new file mode 100644 index 0000000..e4db37d --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/EsSyncUtils.java @@ -0,0 +1,159 @@ +package cn.novalon.gym.manage.member.util; + +import cn.hutool.core.bean.BeanUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.elasticsearch.repository.ReactiveElasticsearchRepository; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +/** + * 通用 ES 同步工具类 + * + * 使用方式: + * + * 1. 注入工具类 + * @Autowired private EsSyncUtils esSyncUtils; + * + * 2. 同步数据到 ES(不返回结果,适合 doOnSuccess) + * esSyncUtils.sync(Member.class, MemberES.class, member, memberESRepository); + * + * 3. 同步数据到 ES(返回 Mono,适合链式调用) + * esSyncUtils.syncToES(Member.class, MemberES.class, member, memberESRepository).subscribe(); + * + * 4. 如果 Repository 是单例,可以先绑定 + * var syncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository); + * syncer.sync(member); + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EsSyncUtils { + + /** + * 同步实体到 ES(不返回结果,适合 doOnSuccess) + * + * @param sourceClass 源实体类(如 Member.class) + * @param targetClass 目标ES实体类(如 MemberES.class) + * @param source 源实体对象 + * @param repository ES Repository + * @param 源实体类型 + * @param ES实体类型 + * @param ID类型 + */ + public void sync(Class sourceClass, Class targetClass, + S source, ReactiveElasticsearchRepository repository) { + if (source == null) { + log.warn("同步 ES 失败:源实体为空"); + return; + } + + try { + T target = BeanUtil.toBean(source, targetClass); + repository.save(target).subscribe( + success -> log.debug("同步到 ES 成功, 类型: {}", targetClass.getSimpleName()), + error -> log.error("同步到 ES 失败, 类型: {}", targetClass.getSimpleName(), error) + ); + } catch (Exception e) { + log.error("转换 ES 实体失败, 源类型: {}, 目标类型: {}", + sourceClass.getSimpleName(), targetClass.getSimpleName(), e); + } + } + + /** + * 同步实体到 ES(返回 Mono,适合链式调用) + * + * @param sourceClass 源实体类 + * @param targetClass 目标ES实体类 + * @param source 源实体对象 + * @param repository ES Repository + * @return Mono + */ + public Mono syncToES(Class sourceClass, Class targetClass, + S source, ReactiveElasticsearchRepository repository) { + if (source == null) { + log.warn("同步 ES 失败:源实体为空"); + return Mono.empty(); + } + + try { + T target = BeanUtil.toBean(source, targetClass); + return repository.save(target) + .doOnSuccess(t -> log.debug("同步到 ES 成功, 类型: {}", targetClass.getSimpleName())) + .doOnError(e -> log.error("同步到 ES 失败, 类型: {}", targetClass.getSimpleName(), e)) + .then(); + } catch (Exception e) { + log.error("转换 ES 实体失败, 源类型: {}, 目标类型: {}", + sourceClass.getSimpleName(), targetClass.getSimpleName(), e); + return Mono.empty(); + } + } + + /** + * 绑定 Repository,返回一个针对特定实体类型的同步器 + * + * @param sourceClass 源实体类 + * @param targetClass 目标ES实体类 + * @param repository ES Repository + * @return 实体同步器 + */ + public EntitySyncer bind(Class sourceClass, Class targetClass, + ReactiveElasticsearchRepository repository) { + return new EntitySyncer<>(sourceClass, targetClass, repository); + } + + /** + * 实体同步器(绑定特定类型的同步器,避免重复传 Class) + * + * @param 源实体类型 + * @param ES实体类型 + * @param ID类型 + */ + public static class EntitySyncer { + private final Class sourceClass; + private final Class targetClass; + private final ReactiveElasticsearchRepository repository; + + public EntitySyncer(Class sourceClass, Class targetClass, + ReactiveElasticsearchRepository repository) { + this.sourceClass = sourceClass; + this.targetClass = targetClass; + this.repository = repository; + } + + /** + * 同步(不返回结果) + */ + public void sync(S source) { + if (source == null) return; + try { + T target = BeanUtil.toBean(source, targetClass); + repository.save(target).subscribe( + success -> log.debug("同步到 ES 成功, 类型: {}", targetClass.getSimpleName()), + error -> log.error("同步到 ES 失败, 类型: {}", targetClass.getSimpleName(), error) + ); + } catch (Exception e) { + log.error("转换 ES 实体失败, 源类型: {}, 目标类型: {}", + sourceClass.getSimpleName(), targetClass.getSimpleName(), e); + } + } + + /** + * 同步(返回 Mono) + */ + public Mono syncMono(S source) { + if (source == null) return Mono.empty(); + try { + T target = BeanUtil.toBean(source, targetClass); + return repository.save(target) + .doOnSuccess(t -> log.debug("同步到 ES 成功, 类型: {}", targetClass.getSimpleName())) + .doOnError(e -> log.error("同步到 ES 失败, 类型: {}", targetClass.getSimpleName(), e)) + .then(); + } catch (Exception e) { + log.error("转换 ES 实体失败, 源类型: {}, 目标类型: {}", + sourceClass.getSimpleName(), targetClass.getSimpleName(), e); + return Mono.empty(); + } + } + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/MemberNoGenerator.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/MemberNoGenerator.java new file mode 100644 index 0000000..2163923 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/MemberNoGenerator.java @@ -0,0 +1,53 @@ +package cn.novalon.gym.manage.member.util; + +import lombok.extern.slf4j.Slf4j; + +import java.security.SecureRandom; + +/** + * 会员号生成器 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +public class MemberNoGenerator { + + // 会员号前缀 + private static final String PREFIX = "GYM"; + + // 随机数长度 + private static final int RANDOM_LENGTH = 8; + + // 字符集(排除易混淆字符 0/O, 1/I/l + private static final String CHARACTERS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + + // SecureRandom 实例(线程安全) + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + // 生成会员号 + public static String generate() { + StringBuilder sb = new StringBuilder(PREFIX); + + for (int i = 0; i < RANDOM_LENGTH; i++) { + int index = SECURE_RANDOM.nextInt(CHARACTERS.length()); + sb.append(CHARACTERS.charAt(index)); + } + + String memberNo = sb.toString(); + log.debug("生成会员号: {}", memberNo); + + return memberNo; + } + + // 批量生成会员号 + // count 生成数量 + public static String[] generateBatch(int count) { + String[] memberNos = new String[count]; + for (int i = 0; i < count; i++) { + memberNos[i] = generate(); + } + return memberNos; + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/RedisUtil.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/RedisUtil.java new file mode 100644 index 0000000..ea49f50 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/RedisUtil.java @@ -0,0 +1,72 @@ +package cn.novalon.gym.manage.member.util; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +/** + * Redis 工具类(响应式版本) + * + * @author liwentao + * @date 2026/5/15 + */ +@Component +public class RedisUtil { + + @Autowired + private ReactiveRedisTemplate reactiveRedisTemplate; + + /** + * 设置值 + */ + public Mono set(String key, Object value) { + return reactiveRedisTemplate.opsForValue().set(key, value); + } + + /** + * 设置值并指定过期时间(秒) + */ + public Mono setWithExpire(String key, Object value, long timeoutSeconds) { + return reactiveRedisTemplate.opsForValue().set(key, value, Duration.ofSeconds(timeoutSeconds)); + } + + /** + * 获取值 + */ + @SuppressWarnings("unchecked") + public Mono get(String key, Class clazz) { + return reactiveRedisTemplate.opsForValue().get(key) + .map(obj -> clazz.isInstance(obj) ? (T) obj : null); + } + + /** + * 获取值(返回 Object) + */ + public Mono get(String key) { + return reactiveRedisTemplate.opsForValue().get(key); + } + + /** + * 删除key + */ + public Mono delete(String key) { + return reactiveRedisTemplate.delete(key); + } + + /** + * 判断key是否存在 + */ + public Mono hasKey(String key) { + return reactiveRedisTemplate.hasKey(key); + } + + /** + * 设置过期时间(秒) + */ + public Mono expire(String key, long timeoutSeconds) { + return reactiveRedisTemplate.expire(key, Duration.ofSeconds(timeoutSeconds)); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/WechatPhoneUtil.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/WechatPhoneUtil.java new file mode 100644 index 0000000..a9c6c55 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/WechatPhoneUtil.java @@ -0,0 +1,65 @@ +package cn.novalon.gym.manage.member.util; + +import cn.novalon.gym.manage.member.service.WechatApiService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +/** + * 微信手机号获取工具类 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Component +@RequiredArgsConstructor +public class WechatPhoneUtil { + + private final WechatApiService wechatApiService; + + /** + * 通过phoneCode获取手机号 + * + * @param phoneCode 手机号 code + * @return Mono 手机号,获取失败则返回 null + */ + public Mono getPhoneNumber(String phoneCode) { + if (phoneCode == null || phoneCode.isEmpty()) { + log.debug("未提供phoneCode获取手机号"); + return Mono.empty(); + } + + log.info("开始获取手机号, phoneCode: {}", phoneCode); + + return wechatApiService.getPhoneNumber(phoneCode) + .doOnSuccess(phoneNumber -> { + if (phoneNumber != null && !phoneNumber.isEmpty()) { + log.info("获取手机号成功: {}", maskPhone(phoneNumber)); + } else { + log.warn("获取手机号失败"); + } + }) + .doOnError(e -> { + log.warn("获取手机号失败 {}", e.getMessage()); + }) + .onErrorResume(e -> { + return Mono.empty(); + }); + } + + /** + * 手机号脱敏处理 + * + * @param phone 手机号 + * @return 脱敏后的手机号,如:138****8000 + */ + public static String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return "***"; + } + return phone.substring(0, 3) + "****" + phone.substring(7); + } +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardInfoVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardInfoVO.java new file mode 100644 index 0000000..785f890 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardInfoVO.java @@ -0,0 +1,92 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 会员卡类型响应 VO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberCardInfoVO { + + /** + * 主键ID + */ + private Long id; + + /** + * 会员卡ID + */ + private Long memberCardId; + + /** + * 会员卡名称 + */ + private String memberCardName; + + /** + * 会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡 + */ + private String memberCardType; + + /** + * 卡类型描述 + */ + private String memberCardTypeDesc; + + /** + * 会员卡价格 + */ + private BigDecimal memberCardPrice; + + /** + * 有效天数(时长卡用) + */ + private Integer memberCardValidityDays; + + /** + * 总次数(次卡用) + */ + private Integer memberCardTotalTimes; + + /** + * 面额(储值卡用) + */ + private BigDecimal memberCardAmount; + + /** + * 状态:0-下架, 1-上架 + */ + private Integer memberCardStatus; + + /** + * 状态描述 + */ + private String memberCardStatusDesc; + + /** + * 扩展配置(JSON格式) + */ + private String extraConfig; + + /** + * 创建时间 + */ + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardRecordVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardRecordVO.java new file mode 100644 index 0000000..966590d --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardRecordVO.java @@ -0,0 +1,71 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 会员卡记录响应 VO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberCardRecordVO { + + /** + * 会员卡记录ID + */ + private Long memberCardRecordId; + + /** + * 会员ID + */ + private Long memberId; + + /** + * 会员卡类型ID + */ + private Long memberCardId; + + /** + * 会员卡名称 + */ + private String memberCardName; + + /** + * 状态:ACTIVE-有效, USED_UP-用完, EXPIRED-过期, REFUNDED-已退款 + */ + private String status; + + /** + * 剩余次数 + */ + private Integer remainingTimes; + + /** + * 剩余金额 + */ + private Double remainingAmount; + + /** + * 到期时间 + */ + private LocalDateTime expireTime; + + /** + * 购买时间 + */ + private LocalDateTime purchaseTime; + + /** + * 创建时间 + */ + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardTransactionVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardTransactionVO.java new file mode 100644 index 0000000..50d4038 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardTransactionVO.java @@ -0,0 +1,81 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 会员卡交易流水响应 VO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberCardTransactionVO { + + /** + * 交易ID + */ + private Long id; + + /** + * 会员卡记录ID + */ + private Long memberCardRecordId; + + /** + * 会员ID + */ + private Long memberId; + + /** + * 会员卡类型ID + */ + private Long memberCardId; + + /** + * 操作类型:PURCHASE-购买, DEDUCT-扣次/扣费, RENEW-续费, REFUND-退款, EXPIRE-过期 + */ + private String operationType; + + /** + * 变动次数 + */ + private Integer changeAmount; + + /** + * 变动金额 + */ + private Double changeBalance; + + /** + * 变动后剩余次数 + */ + private Integer afterRemainingCount; + + /** + * 变动后剩余金额 + */ + private Double afterRemainingBalance; + + /** + * 关联业务类型 + */ + private String relatedBizType; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + private LocalDateTime createdAt; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardVO.java new file mode 100644 index 0000000..6165bf0 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberCardVO.java @@ -0,0 +1,77 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 会员卡类型响应 VO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberCardVO { + + /** + * 会员卡ID + */ + private Long memberCardId; + + /** + * 会员卡名称 + */ + private String memberCardName; + + /** + * 会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡 + */ + private String memberCardType; + + /** + * 会员卡价格 + */ + private BigDecimal memberCardPrice; + + /** + * 有效天数(时长卡用) + */ + private Integer memberCardValidityDays; + + /** + * 总次数(次卡用) + */ + private Integer memberCardTotalTimes; + + /** + * 面额(储值卡用) + */ + private BigDecimal memberCardAmount; + + /** + * 状态:0-下架, 1-上架 + */ + private Integer memberCardStatus; + + /** + * 扩展配置(JSON格式) + */ + private String extraConfig; + + /** + * 创建时间 + */ + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + private LocalDateTime updatedAt; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberDetailVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberDetailVO.java new file mode 100644 index 0000000..51ea67c --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberDetailVO.java @@ -0,0 +1,96 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; + +/** + * 会员详情 VO(管理端使用) + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberDetailVO { + + // ==================== 会员基础信息 ==================== + + /** + * 会员ID + */ + private Long id; + + /** + * 会员编号 + */ + private String memberNo; + + /** + * 昵称 + */ + private String nickname; + + /** + * 手机号(脱敏显示) + */ + private String phone; + + /** + * 性别描述 + */ + private String genderDesc; + + /** + * 生日 + */ + private Date birthday; + + /** + * 地址 + */ + private String address; + + /** + * 头像URL + */ + private String avatar; + + /** + * 是否关注服务号 + */ + private Boolean subscribed; + + /** + * 最后登录时间 + */ + private LocalDateTime lastLoginAt; + + /** + * 注册时间 + */ + private LocalDateTime createdAt; + + // ==================== 会员卡信息 ==================== + + /** + * 会员持有的卡列表 + */ + private List memberCards; + + /** + * 有效会员卡数量 + */ + private Integer activeCardCount; + + /** + * 过期/用完会员卡数量 + */ + private Integer inactiveCardCount; +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberInfoVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberInfoVO.java new file mode 100644 index 0000000..f3e763d --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberInfoVO.java @@ -0,0 +1,51 @@ +package cn.novalon.gym.manage.member.vo; + +import cn.novalon.gym.manage.member.enums.GenderEnum; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.Date; + +/** + * 会员信息 VO + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberInfoVO { + + // 会员 ID + private Long id; + + // 昵称 + private String nickname; + + // 手机号(脱敏) + private String phone; + + // 性别 + private GenderEnum gender; + + // 性别描述 + private String genderDesc; + + // 生日 + private LocalDate birthday; + + // 头像 + private String avatar; + + // 是否已绑定手机号 + private Boolean hasPhone; + + // 是否已关注公众号 + private Boolean isSubscribed; +} \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/RefundApplicationVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/RefundApplicationVO.java new file mode 100644 index 0000000..9f6d99c --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/RefundApplicationVO.java @@ -0,0 +1,77 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 退款申请响应 VO + * + * @author 付嘉 + * @date 2026-05-27 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefundApplicationVO { + + /** + * 申请ID + */ + private Long id; + + /** + * 会员卡记录ID + */ + private Long recordId; + + /** + * 会员ID + */ + private Long memberId; + + /** + * 状态:PENDING-待审核, APPROVED-已批准, REJECTED-已拒绝, PROCESSING-处理中, SUCCESS-成功, FAILED-失败 + */ + private String status; + + /** + * 退款原因 + */ + private String reason; + + /** + * 申请时间 + */ + private LocalDateTime applyTime; + + /** + * 审核时间 + */ + private LocalDateTime auditTime; + + /** + * 审核人ID + */ + private Long auditorId; + + /** + * 审核备注 + */ + private String auditRemark; + + /** + * 退款金额 + */ + private BigDecimal refundAmount; + + /** + * 创建时间 + */ + private LocalDateTime createdAt; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/WechatLoginVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/WechatLoginVO.java new file mode 100644 index 0000000..b47370b --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/WechatLoginVO.java @@ -0,0 +1,38 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 微信登录VO + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WechatLoginVO { + + // 会员 ID + private Long memberId; + + // Access Token(访问令牌) + private String accessToken; + + // Refresh Token(刷新令牌) + private String refreshToken; + + // Token 过期时间(秒) + private Integer expiresIn; + + // 是否为新用户 + private Boolean isNewUser; + + // 是否需要补全信息(昵称、手机号等) + private Boolean needCompleteInfo; +} diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/WechatUserInfoVO.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/WechatUserInfoVO.java new file mode 100644 index 0000000..8fc4735 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/WechatUserInfoVO.java @@ -0,0 +1,72 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.util.List; + +/** + * 微信用户信息 VO + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WechatUserInfoVO { + + // 用户是否关注该公众号(true-已关注,false-未关注) + private Boolean subscribe; + + // 服务号 OpenID + private String openid; + + // 用户昵称 + private String nickname; + + // 性别(1-男,2-女,0-未知) + private Integer sex; + + // 国家 + private String country; + + // 省份 + private String province; + + // 城市 + private String city; + + // 语言 + private String language; + + // 头像 URL + private String headimgurl; + + // 关注时间 + private Long subscribeTime; + + // UnionID + private String unionid; + + // 公众号运营者对粉丝的备注 + private String remark; + + // 用户所在的分组 ID + private Integer groupid; + + // 用户被打上的标签 ID 列表 + private List tagidList; + + // 关注来源 + private Integer subscribeScene; + + // 二维码场景值 + private String qrScene; + + // 二维码场景值字符串 + private String qrSceneStr; +} diff --git a/gym-manage-api/gym-member/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/gym-manage-api/gym-member/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..18cc11f --- /dev/null +++ b/gym-manage-api/gym-member/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.novalon.gym.manage.member.config.WechatProperties diff --git a/gym-manage-api/gym-member/src/main/resources/db/schema.sql b/gym-manage-api/gym-member/src/main/resources/db/schema.sql new file mode 100644 index 0000000..b76f2df --- /dev/null +++ b/gym-manage-api/gym-member/src/main/resources/db/schema.sql @@ -0,0 +1,281 @@ +-- ============================================ +-- 1. member_user 表(会员表) +-- ============================================ + +-- Step 1: 删除已存在的表(如果需要重建) +-- DROP TABLE IF EXISTS member_user CASCADE; + +-- Step 2: 创建 member_user 表 +CREATE TABLE IF NOT EXISTS member_user ( + -- ========== 主键和基础字段(来自BaseEntity)========== + id BIGSERIAL PRIMARY KEY, -- 主键ID,自增 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + + -- ========== 会员核心字段 ========== + member_no VARCHAR(50) NOT NULL UNIQUE, -- 会员编号(唯一) + nickname VARCHAR(100), -- 昵称 + phone VARCHAR(255), -- 手机号(AES加密存储) + gender INTEGER DEFAULT 0, -- 性别:0-未知,1-男,2-女 + birthday DATE, -- 生日 + address VARCHAR(500), -- 地址 + avatar VARCHAR(500), -- 头像URL + subscribed BOOLEAN DEFAULT FALSE, -- 是否关注服务号 + last_login_at TIMESTAMP, -- 最后登录时间 + + -- ========== 微信相关字段 ========== + union_id VARCHAR(100), -- 微信UnionID(跨应用唯一标识) + miniapp_open_id VARCHAR(100), -- 小程序OpenID + official_open_id VARCHAR(100), -- 服务号OpenID + + -- ========== 软删除字段 ========== + is_deleted BOOLEAN DEFAULT FALSE -- 是否删除(软删除标记) +); + +-- Step 3: 创建索引 +-- 会员编号索引(唯一索引,加速查询) +CREATE UNIQUE INDEX IF NOT EXISTS idx_member_user_member_no ON member_user(member_no); + +-- UnionID索引(加速跨平台用户查找) +CREATE INDEX IF NOT EXISTS idx_member_user_union_id ON member_user(union_id); + +-- 小程序OpenID索引(加速小程序登录查询) +CREATE INDEX IF NOT EXISTS idx_member_user_miniapp_openid ON member_user(miniapp_open_id); + +-- 服务号OpenID索引(加速服务号事件处理) +CREATE INDEX IF NOT EXISTS idx_member_user_official_openid ON member_user(official_open_id); + +-- 手机号索引(加速手机号查询和去重) +CREATE INDEX IF NOT EXISTS idx_member_user_phone ON member_user(phone); + +-- 软删除索引(加速查询未删除的记录) +CREATE INDEX IF NOT EXISTS idx_member_user_is_deleted ON member_user(is_deleted); + +-- Step 4: 添加注释 +COMMENT ON TABLE member_user IS '会员表'; + +COMMENT ON COLUMN member_user.id IS '主键ID'; +COMMENT ON COLUMN member_user.created_at IS '创建时间'; +COMMENT ON COLUMN member_user.updated_at IS '更新时间'; + +COMMENT ON COLUMN member_user.member_no IS '会员编号(唯一,格式:MEM + 8位随机字符)'; +COMMENT ON COLUMN member_user.nickname IS '昵称'; +COMMENT ON COLUMN member_user.phone IS '手机号(AES-128-CBC加密存储)'; +COMMENT ON COLUMN member_user.gender IS '性别:0-未知,1-男,2-女'; +COMMENT ON COLUMN member_user.birthday IS '生日'; +COMMENT ON COLUMN member_user.address IS '地址'; +COMMENT ON COLUMN member_user.avatar IS '头像URL'; +COMMENT ON COLUMN member_user.subscribed IS '是否关注服务号:true-已关注,false-未关注'; +COMMENT ON COLUMN member_user.last_login_at IS '最后登录时间'; + +COMMENT ON COLUMN member_user.union_id IS '微信UnionID(用户在开放平台的唯一标识,跨应用相同)'; +COMMENT ON COLUMN member_user.miniapp_open_id IS '小程序OpenID(用户在当前小程序的唯一标识)'; +COMMENT ON COLUMN member_user.official_open_id IS '服务号OpenID(用户在当前服务号的唯一标识)'; + +COMMENT ON COLUMN member_user.is_deleted IS '是否删除(软删除标记):false-正常,true-已删除'; + + +-- ============================================ +-- 2. wechat_user 表(微信用户表) +-- ============================================ + +-- Step 1: 删除已存在的表(如果需要重建) +-- DROP TABLE IF EXISTS wechat_user CASCADE; + +-- Step 2: 创建 wechat_user 表 +CREATE TABLE IF NOT EXISTS wechat_user ( + -- ========== 主键和基础字段(来自BaseEntity)========== + id BIGSERIAL PRIMARY KEY, -- 主键ID,自增 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + + -- ========== 关联字段 ========== + member_id BIGINT NOT NULL, -- 会员ID(外键) + + -- ========== 微信标识字段 ========== + union_id VARCHAR(100), -- 微信UnionID + miniapp_openid VARCHAR(100), -- 小程序OpenID + mp_openid VARCHAR(100), -- 服务号OpenID + + -- ========== 关注状态字段 ========== + is_subscribed BOOLEAN DEFAULT FALSE, -- 是否关注服务号 + subscribe_time TIMESTAMP, -- 首次关注时间 + unsubscribe_time TIMESTAMP -- 最后一次取消关注时间 +); + +-- Step 3: 创建外键约束 +ALTER TABLE wechat_user + ADD CONSTRAINT fk_wechat_user_member + FOREIGN KEY (member_id) REFERENCES member_user(id) ON DELETE CASCADE; + +-- Step 4: 创建索引 +-- UnionID索引(加速跨平台用户查找) +CREATE INDEX IF NOT EXISTS idx_wechat_user_union_id ON wechat_user(union_id); + +-- 小程序OpenID索引(加速小程序登录查询) +CREATE INDEX IF NOT EXISTS idx_wechat_user_miniapp_openid ON wechat_user(miniapp_openid); + +-- 服务号OpenID索引(加速服务号事件处理) +CREATE INDEX IF NOT EXISTS idx_wechat_user_mp_openid ON wechat_user(mp_openid); + +-- 会员ID索引(加速关联查询) +CREATE INDEX IF NOT EXISTS idx_wechat_user_member_id ON wechat_user(member_id); + +-- Step 5: 添加注释 +COMMENT ON TABLE wechat_user IS '微信用户表'; + +COMMENT ON COLUMN wechat_user.id IS '主键ID'; +COMMENT ON COLUMN wechat_user.created_at IS '创建时间'; +COMMENT ON COLUMN wechat_user.updated_at IS '更新时间'; + +COMMENT ON COLUMN wechat_user.member_id IS '会员ID(关联 member_user 表的 id 字段)'; +COMMENT ON COLUMN wechat_user.union_id IS '微信UnionID(用户在开放平台的唯一标识)'; +COMMENT ON COLUMN wechat_user.miniapp_openid IS '小程序OpenID(用户在当前小程序的唯一标识)'; +COMMENT ON COLUMN wechat_user.mp_openid IS '服务号OpenID(用户在当前服务号的唯一标识)'; + +COMMENT ON COLUMN wechat_user.is_subscribed IS '是否关注服务号:true-已关注,false-未关注'; +COMMENT ON COLUMN wechat_user.subscribe_time IS '首次关注时间'; +COMMENT ON COLUMN wechat_user.unsubscribe_time IS '最后一次取消关注时间'; + + +-- ============================================ +-- 3. member_card 表(会员卡类型表) +-- ============================================ +CREATE TABLE IF NOT EXISTS member_card ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + member_card_id BIGSERIAL, + member_card_name VARCHAR(100) NOT NULL, + member_card_type VARCHAR(20) NOT NULL, + member_card_price DECIMAL(10, 2) NOT NULL, + member_card_validity_days INTEGER, + member_card_total_times INTEGER, + member_card_amount DECIMAL(10, 2), + member_card_status INTEGER DEFAULT 1 NOT NULL, + extra_config TEXT DEFAULT '{}' +); + +COMMENT ON TABLE member_card IS '会员卡类型表'; +COMMENT ON COLUMN member_card.member_card_id IS '会员卡ID'; +COMMENT ON COLUMN member_card.member_card_name IS '会员卡名称'; +COMMENT ON COLUMN member_card.member_card_type IS '会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡'; +COMMENT ON COLUMN member_card.member_card_price IS '会员卡价格'; +COMMENT ON COLUMN member_card.member_card_validity_days IS '有效天数(时长卡用)'; +COMMENT ON COLUMN member_card.member_card_total_times IS '总次数(次卡用)'; +COMMENT ON COLUMN member_card.member_card_amount IS '面额(储值卡用)'; +COMMENT ON COLUMN member_card.member_card_status IS '状态:0-下架, 1-上架'; +COMMENT ON COLUMN member_card.extra_config IS '扩展配置(JSON格式)'; + + +-- ============================================ +-- 4. member_card_record 表(会员卡记录表) +-- ============================================ +CREATE TABLE IF NOT EXISTS member_card_record ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + member_card_record_id BIGSERIAL, + member_id BIGINT NOT NULL, + member_card_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + remaining_times INTEGER DEFAULT 0, + remaining_amount DECIMAL(10, 2) DEFAULT 0.00, + expire_time TIMESTAMP, + source_order_id BIGINT, + purchase_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + version INTEGER DEFAULT 0 NOT NULL, + card_composition TEXT +); + +-- 索引优化 +CREATE INDEX IF NOT EXISTS idx_member_card_record_member_id ON member_card_record(member_id); +CREATE INDEX IF NOT EXISTS idx_member_card_record_status ON member_card_record(status); +CREATE INDEX IF NOT EXISTS idx_member_card_record_expire_time ON member_card_record(expire_time); +CREATE INDEX IF NOT EXISTS idx_member_card_record_member_status ON member_card_record(member_id, status); +CREATE INDEX IF NOT EXISTS idx_member_card_record_status_expire ON member_card_record(status, expire_time) + WHERE status = 'ACTIVE'; + +COMMENT ON TABLE member_card_record IS '会员卡记录表(会员持有的卡)'; +COMMENT ON COLUMN member_card_record.member_card_record_id IS '会员卡记录ID'; +COMMENT ON COLUMN member_card_record.member_id IS '会员ID'; +COMMENT ON COLUMN member_card_record.member_card_id IS '会员卡类型ID'; +COMMENT ON COLUMN member_card_record.status IS '状态:ACTIVE-有效, USED_UP-用完, EXPIRED-过期, REFUNDED-已退款'; +COMMENT ON COLUMN member_card_record.remaining_times IS '剩余次数'; +COMMENT ON COLUMN member_card_record.remaining_amount IS '剩余金额'; +COMMENT ON COLUMN member_card_record.expire_time IS '到期时间'; +COMMENT ON COLUMN member_card_record.source_order_id IS '来源订单ID'; +COMMENT ON COLUMN member_card_record.purchase_time IS '购买时间'; +COMMENT ON COLUMN member_card_record.version IS '乐观锁版本号'; +COMMENT ON COLUMN member_card_record.card_composition IS '卡片组成(JSON格式,用于组合卡)'; + + +-- ============================================ +-- 5. member_card_transactions 表(会员卡交易流水表) +-- ============================================ +CREATE TABLE IF NOT EXISTS member_card_transactions ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + member_card_record_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + member_card_id BIGINT NOT NULL, + operation_type VARCHAR(20) NOT NULL, + change_amount INTEGER DEFAULT 0, + change_balance DECIMAL(10, 2) DEFAULT 0.00, + after_remaining_count INTEGER DEFAULT 0, + after_remaining_balance DECIMAL(10, 2) DEFAULT 0.00, + related_biz_type VARCHAR(20), + source_order_id BIGINT, + remark VARCHAR(500), + is_archived BOOLEAN DEFAULT FALSE, + archived_at TIMESTAMP +); + +-- 索引优化 +CREATE INDEX IF NOT EXISTS idx_member_card_transactions_member_id ON member_card_transactions(member_id); +CREATE INDEX IF NOT EXISTS idx_member_card_transactions_record_id ON member_card_transactions(member_card_record_id); +CREATE INDEX IF NOT EXISTS idx_member_card_transactions_created_at ON member_card_transactions(created_at); +CREATE INDEX IF NOT EXISTS idx_member_card_transactions_member_type_time + ON member_card_transactions(member_id, operation_type, created_at); + +COMMENT ON TABLE member_card_transactions IS '会员卡交易流水表'; +COMMENT ON COLUMN member_card_transactions.operation_type IS '操作类型:PURCHASE-购买, DEDUCT-扣次/扣费, RENEW-续费, REFUND-退款, EXPIRE-过期'; +COMMENT ON COLUMN member_card_transactions.change_amount IS '变动次数'; +COMMENT ON COLUMN member_card_transactions.change_balance IS '变动金额'; +COMMENT ON COLUMN member_card_transactions.after_remaining_count IS '变动后剩余次数'; +COMMENT ON COLUMN member_card_transactions.after_remaining_balance IS '变动后剩余金额'; +COMMENT ON COLUMN member_card_transactions.related_biz_type IS '关联业务类型:GROUP_CLASS-团课, PT_CLASS-私教, CHECK_IN-签到'; +COMMENT ON COLUMN member_card_transactions.is_archived IS '是否已归档'; +COMMENT ON COLUMN member_card_transactions.archived_at IS '归档时间'; + + +-- ============================================ +-- 6. refund_application 表(退款申请表) +-- ============================================ +CREATE TABLE IF NOT EXISTS refund_application ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + record_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reason VARCHAR(500), + apply_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + audit_time TIMESTAMP, + auditor_id BIGINT, + audit_remark VARCHAR(500), + refund_amount DECIMAL(10, 2) +); + +CREATE INDEX IF NOT EXISTS idx_refund_application_record_id ON refund_application(record_id); +CREATE INDEX IF NOT EXISTS idx_refund_application_status ON refund_application(status); + +COMMENT ON TABLE refund_application IS '退款申请表'; +COMMENT ON COLUMN refund_application.status IS '状态:PENDING-待审核, APPROVED-已批准, REJECTED-已拒绝, PROCESSING-处理中, SUCCESS-成功, FAILED-失败'; diff --git a/gym-manage-api/gym-member/src/main/resources/member-config.yml b/gym-manage-api/gym-member/src/main/resources/member-config.yml new file mode 100644 index 0000000..a76b243 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/resources/member-config.yml @@ -0,0 +1,24 @@ +# 微信配置(测试环境使用模拟数据) +wechat: + # Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境) + mock-enabled: false + + miniapp: + app-id: ${WECHAT_MINIAPP_APP_ID} + app-secret: ${WECHAT_MINIAPP_SECRET} + + mp: + app-id: ${WECHAT_MP_APP_ID} + app-secret: ${WECHAT_MP_SECRET} + token: ${WECHAT_MP_TOKEN} + aes-key: ${WECHAT_MP_AESKEY} + callback-url: ${WECHAT_MP_CALLBACK_URL} + + # 手机号加密配置 + phone-encryption: + secret-key: ${PHONE_ENCRYPTION_SECRET_KEY} + iv: ${PHONE_ENCRYPTION_IV} + +spring: + elasticsearch: + uris: http://localhost:9200 \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/test/java/cn/novalon/gym/manage/member/MemberModuleTest.java b/gym-manage-api/gym-member/src/test/java/cn/novalon/gym/manage/member/MemberModuleTest.java new file mode 100644 index 0000000..799f865 --- /dev/null +++ b/gym-manage-api/gym-member/src/test/java/cn/novalon/gym/manage/member/MemberModuleTest.java @@ -0,0 +1,16 @@ +package cn.novalon.gym.manage.member; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * 会员模块测试类 + */ +@SpringBootTest +public class MemberModuleTest { + + @Test + void contextLoads() { + // 测试Spring上下文是否能正常加载 + } +} diff --git a/gym-manage-api/manage-app/pom.xml b/gym-manage-api/manage-app/pom.xml index 45fcc60..2644308 100644 --- a/gym-manage-api/manage-app/pom.xml +++ b/gym-manage-api/manage-app/pom.xml @@ -38,6 +38,12 @@ manage-db ${project.version} + + cn.novalon.gym.manage + gym-member + ${project.version} + + org.springframework.boot spring-boot-starter-webflux @@ -133,6 +139,10 @@ org.springdoc springdoc-openapi-starter-webflux-ui + + org.springframework.boot + spring-boot-starter-data-redis + diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java index f74c2f7..a87aaef 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java @@ -10,6 +10,8 @@ import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDeta import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; +import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories; import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; import org.springframework.web.server.WebFilter; @@ -17,8 +19,13 @@ import java.util.List; @SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = { ReactiveUserDetailsServiceAutoConfiguration.class }) -@EnableR2dbcRepositories(basePackages = { "cn.novalon.gym.manage.db.dao", - "cn.novalon.gym.manage.sys.audit.repository" }) +@EnableR2dbcRepositories(basePackages = { + "cn.novalon.gym.manage.db.dao", + "cn.novalon.gym.manage.sys.audit.repository" , + "cn.novalon.gym.manage.gymmembercard.dao", + "cn.novalon.gym.manage.member.repository" +}) +@EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository") public class ManageApplication { private static final Logger logger = LoggerFactory.getLogger(ManageApplication.class); diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java index c869da3..996c1d1 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java @@ -1,20 +1,26 @@ package cn.novalon.gym.manage.app.config; -import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler; -import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler; -import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler; -import cn.novalon.gym.manage.sys.handler.dictionary.DictionaryHandler; -import cn.novalon.gym.manage.sys.handler.dict.SysDictHandler; -import cn.novalon.gym.manage.sys.handler.log.SysLogHandler; -import cn.novalon.gym.manage.sys.handler.log.OperationLogHandler; -import cn.novalon.gym.manage.sys.handler.menu.MenuHandler; -import cn.novalon.gym.manage.sys.handler.role.SysRoleHandler; -import cn.novalon.gym.manage.sys.handler.permission.SysPermissionHandler; -import cn.novalon.gym.manage.sys.handler.stats.StatsHandler; -import cn.novalon.gym.manage.sys.handler.user.SysUserHandler; + +import cn.novalon.gym.manage.file.handler.SysFileHandler; +import cn.novalon.gym.manage.member.handler.MemberCardHandler; +import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler; +import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler; +import cn.novalon.gym.manage.member.handler.MemberHandler; +import cn.novalon.gym.manage.member.handler.WechatAuthHandler; import cn.novalon.gym.manage.notify.handler.SysNoticeHandler; import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler; -import cn.novalon.gym.manage.file.handler.SysFileHandler; +import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler; +import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler; +import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler; +import cn.novalon.gym.manage.sys.handler.dict.SysDictHandler; +import cn.novalon.gym.manage.sys.handler.dictionary.DictionaryHandler; +import cn.novalon.gym.manage.sys.handler.log.OperationLogHandler; +import cn.novalon.gym.manage.sys.handler.log.SysLogHandler; +import cn.novalon.gym.manage.sys.handler.menu.MenuHandler; +import cn.novalon.gym.manage.sys.handler.permission.SysPermissionHandler; +import cn.novalon.gym.manage.sys.handler.role.SysRoleHandler; +import cn.novalon.gym.manage.sys.handler.stats.StatsHandler; +import cn.novalon.gym.manage.sys.handler.user.SysUserHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.server.RouterFunction; @@ -28,7 +34,7 @@ import static org.springframework.web.reactive.function.server.RouterFunctions.r * 文件定义:配置WebFlux函数式路由,将HTTP请求映射到对应的Handler方法 * 涉及业务:用户、角色、字典、菜单、公告、文件等所有RESTful API路由 * 算法:使用RouterFunctions.route()构建函数式路由规则 - * + * * @author 张翔 * @date 2026-03-13 */ @@ -51,12 +57,17 @@ public class SystemRouter { SysUserMessageHandler messageHandler, SysFileHandler fileHandler, SysPermissionHandler permissionHandler, - PasswordDiagnosticHandler passwordDiagnosticHandler) { - + MemberHandler memberHandler, + WechatAuthHandler wechatAuthHandler, + PasswordDiagnosticHandler passwordDiagnosticHandler, + MemberCardHandler memberCardHandler, + MemberCardRecordHandler memberCardRecordHandler, + MemberCardTransactionHandler memberCardTransactionHandler) { + return route() // ========== 诊断路由 ========== .GET("/api/diagnostic/password", passwordDiagnosticHandler::diagnose) - + // ========== 字典路由 ========== .GET("/api/dictionaries", dictionaryHandler::getAllDictionaries) .GET("/api/dictionaries/{id}", dictionaryHandler::getDictionaryById) @@ -65,7 +76,7 @@ public class SystemRouter { .POST("/api/dictionaries", dictionaryHandler::createDictionary) .PUT("/api/dictionaries/{id}", dictionaryHandler::updateDictionary) .DELETE("/api/dictionaries/{id}", dictionaryHandler::deleteDictionary) - + // ========== 用户路由 ========== .GET("/api/users", userHandler::getAllUsers) .GET("/api/users/page", userHandler::getUsersByPage) @@ -84,7 +95,7 @@ public class SystemRouter { .POST("/api/users/{id}/action/restore", userHandler::restoreUser) .GET("/api/users/{id}/roles", userHandler::getUserRoles) .POST("/api/users/{id}/roles", userHandler::assignRoles) - + // ========== 菜单路由 ========== .GET("/api/menus", menuHandler::getAllMenus) .GET("/api/menus/tree", menuHandler::getMenuTree) @@ -92,7 +103,7 @@ public class SystemRouter { .POST("/api/menus", menuHandler::createMenu) .PUT("/api/menus/{id}", menuHandler::updateMenu) .DELETE("/api/menus/{id}", menuHandler::deleteMenu) - + // ========== 角色路由 ========== .GET("/api/roles", roleHandler::getAllRoles) .GET("/api/roles/page", roleHandler::getRolesByPage) @@ -106,7 +117,7 @@ public class SystemRouter { .POST("/api/roles/{id}/restore", roleHandler::restoreRole) .GET("/api/roles/{id}/permissions", permissionHandler::getPermissionsByRoleId) .POST("/api/roles/{id}/permissions", permissionHandler::assignPermissionsToRole) - + // ========== 配置路由 ========== .GET("/api/config", configHandler::getAllConfigs) .GET("/api/config/{id}", configHandler::getConfigById) @@ -114,7 +125,7 @@ public class SystemRouter { .POST("/api/config", configHandler::createConfig) .PUT("/api/config/{id}", configHandler::updateConfig) .DELETE("/api/config/{id}", configHandler::deleteConfig) - + // ========== 日志路由 ========== .GET("/api/logs/login", logHandler::getAllLoginLogs) .GET("/api/logs/login/page", logHandler::getLoginLogsByPage) @@ -134,15 +145,15 @@ public class SystemRouter { .GET("/api/logs/operation/count", operationLogHandler::getOperationLogCount) .GET("/api/logs/operation/{id}", operationLogHandler::getOperationLogById) .POST("/api/logs/operation", operationLogHandler::createOperationLog) - + // ========== 认证路由 ========== .POST("/api/auth/login", authHandler::login) .POST("/api/auth/register", authHandler::register) .POST("/api/auth/logout", authHandler::logout) - + // ========== 统计路由 ========== .GET("/api/stats/overview", statsHandler::getOverview) - + // ========== 数据字典路由 ========== .GET("/api/dict/types", dictHandler::getAllDictTypes) .GET("/api/dict/types/{id}", dictHandler::getDictTypeById) @@ -156,7 +167,7 @@ public class SystemRouter { .POST("/api/dict/data", dictHandler::createDictData) .PUT("/api/dict/data/{id}", dictHandler::updateDictData) .DELETE("/api/dict/data/{id}", dictHandler::deleteDictData) - + // ========== 公告路由 ========== .GET("/api/notices", noticeHandler::getAllNotices) .GET("/api/notices/{id}", noticeHandler::getNoticeById) @@ -164,7 +175,7 @@ public class SystemRouter { .POST("/api/notices", noticeHandler::createNotice) .PUT("/api/notices/{id}", noticeHandler::updateNotice) .DELETE("/api/notices/{id}", noticeHandler::deleteNotice) - + // ========== 消息路由 ========== .GET("/api/messages/user/{userId}", messageHandler::getMessagesByUser) .GET("/api/messages/user/{userId}/unread", messageHandler::getUnreadCount) @@ -172,7 +183,7 @@ public class SystemRouter { .POST("/api/messages", messageHandler::createMessage) .PUT("/api/messages/{id}/read", messageHandler::markAsRead) .DELETE("/api/messages/{id}", messageHandler::deleteMessage) - + // ========== 文件路由 ========== .GET("/api/files", fileHandler::getAllFiles) .GET("/api/files/{id}", fileHandler::getFileById) @@ -182,7 +193,7 @@ public class SystemRouter { .GET("/api/files/{id}/preview", fileHandler::previewFile) .GET("/api/files/preview/{fileName}", fileHandler::previewFileByName) .DELETE("/api/files/{id}", fileHandler::deleteFile) - + // ========== 权限路由 ========== .GET("/api/permissions", permissionHandler::getAllPermissions) .GET("/api/permissions/{id}", permissionHandler::getPermissionById) @@ -192,6 +203,52 @@ public class SystemRouter { .POST("/api/permissions", permissionHandler::createPermission) .PUT("/api/permissions/{id}", permissionHandler::updatePermission) .DELETE("/api/permissions/{id}", permissionHandler::deletePermission) + + // ========== 会员模块路由 - 微信认证 ========== + .POST("/api/member/auth/miniapp/login", wechatAuthHandler::miniappLogin) + .GET("/api/member/auth/mp/callback", wechatAuthHandler::verifyMpSignature) + .POST("/api/member/auth/mp/callback", wechatAuthHandler::mpCallback) + + // ========== 会员模块路由 - 会员信息 ========== + .GET("/api/member/info", memberHandler::getMemberInfo) + .PUT("/api/member/info", memberHandler::updateMemberInfo) + .POST("/api/member/phone/bind", memberHandler::bindPhone) + .GET("/api/member/subscribe/status", memberHandler::checkSubscribeStatus) + + // ========== 会员模块路由 - 管理端 ========== + .POST("/api/admin/member/{id}/phone", memberHandler::adminUpdatePhone) + .GET("/api/admin/member/{id}", memberHandler::adminGetMemberInfo) + .PUT("/api/admin/member/{id}", memberHandler::adminUpdateMemberInfo) + .GET("/api/admin/members", memberHandler::searchMembers) + .GET("/api/admin/members/all", memberHandler::getAllMembers) + + + // ======================================== + // ========== 会员卡管理路由 ============== + // ======================================== + + // ===== 会员卡类型管理 ===== + .GET("/api/member-cards/active", memberCardHandler::getActiveCards) + .GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById) + .POST("/api/member-cards", memberCardHandler::createMemberCard) + + // ===== 会员卡记录管理(核心业务)===== + .POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard) + .POST("/api/member-card-records/{recordId}/renew", memberCardRecordHandler::renewCard) + .POST("/api/member-card-records/{recordId}/use", memberCardRecordHandler::useCard) + .POST("/api/member-card-records/{recordId}/refund", memberCardRecordHandler::refundCard) + .GET("/api/member-card-records/my-cards/{memberId}", memberCardRecordHandler::getMyCards) + .GET("/api/member-card-records/{recordId}", memberCardRecordHandler::getMemberCardRecordById) + .POST("/api/member-card-records/process-expired", memberCardRecordHandler::processExpiredCards) + + // ===== 会员卡交易流水管理 ===== + .POST("/api/member-card-transactions", memberCardTransactionHandler::insertTransaction) + .GET("/api/member-card-transactions", memberCardTransactionHandler::getTransactionsWithConditions) + .GET("/api/member-card-transactions/member/{memberId}", memberCardTransactionHandler::getMemberTransactions) + .GET("/api/member-card-transactions/card/{cardId}", memberCardTransactionHandler::getTransactionsByCardId) + .GET("/api/member-card-transactions/statistics/deduct/{cardId}", memberCardTransactionHandler::getDeductCountByCardId) + .GET("/api/member-card-transactions/statistics/renew", memberCardTransactionHandler::getRenewAmountByTimeRange) + .GET("/api/member-card-transactions/statistics/purchase/{memberId}", memberCardTransactionHandler::getPurchaseAmountByMember) .build(); } diff --git a/gym-manage-api/manage-app/src/main/resources/application-dev.yml b/gym-manage-api/manage-app/src/main/resources/application-dev.yml index 8f5e8dc..443a17e 100644 --- a/gym-manage-api/manage-app/src/main/resources/application-dev.yml +++ b/gym-manage-api/manage-app/src/main/resources/application-dev.yml @@ -12,11 +12,15 @@ spring: max-life-time: 30m acquire-timeout: 3s flyway: - enabled: true + url: jdbc:postgresql://localhost:55432/manage_system + user: novalon + password: novalon123 + enabled: false locations: classpath:db/migration baseline-on-migrate: true validate-on-migrate: true + jwt: secret: novalon-gym-manage-jwt-secret-key-for-development-only-2026 expiration: 86400000 diff --git a/gym-manage-api/manage-app/src/main/resources/application-local.yml b/gym-manage-api/manage-app/src/main/resources/application-local.yml index 2e9c4f6..73b95b3 100644 --- a/gym-manage-api/manage-app/src/main/resources/application-local.yml +++ b/gym-manage-api/manage-app/src/main/resources/application-local.yml @@ -19,7 +19,7 @@ spring: password: 123456 driver-class-name: org.postgresql.Driver flyway: - enabled: true + enabled: false locations: classpath:db/migration baseline-on-migrate: true baseline-version: 0 diff --git a/gym-manage-api/manage-app/src/main/resources/application-test.yml b/gym-manage-api/manage-app/src/main/resources/application-test.yml index 5a55a80..0b1fba2 100644 --- a/gym-manage-api/manage-app/src/main/resources/application-test.yml +++ b/gym-manage-api/manage-app/src/main/resources/application-test.yml @@ -15,7 +15,7 @@ spring: max-life-time: 1h acquire-timeout: 5s flyway: - enabled: true + enabled: false locations: classpath:db/migration baseline-on-migrate: true validate-on-migrate: true diff --git a/gym-manage-api/manage-app/src/main/resources/application.yml b/gym-manage-api/manage-app/src/main/resources/application.yml index 9305b16..9ff6e52 100644 --- a/gym-manage-api/manage-app/src/main/resources/application.yml +++ b/gym-manage-api/manage-app/src/main/resources/application.yml @@ -29,7 +29,7 @@ spring: password: ${DB_PASSWORD:postgres} driver-class-name: org.postgresql.Driver flyway: - enabled: true + enabled: false locations: classpath:db/migration baseline-on-migrate: true baseline-version: 0 @@ -38,6 +38,10 @@ spring: user: name: disabled password: disabled + profiles: + active: dev + config: + import: classpath:member-config.yml management: endpoints: diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/HtmlEscapeUtil.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/HtmlEscapeUtil.java new file mode 100644 index 0000000..63fbafb --- /dev/null +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/HtmlEscapeUtil.java @@ -0,0 +1,117 @@ +package cn.novalon.gym.manage.common.util; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * HTML 转义工具类 + * 防止 XSS 注入攻击 + * + * @author 付嘉 + * @date 2026-05-29 + */ +public class HtmlEscapeUtil { + + private static final Map ESCAPE_MAP = new HashMap<>(); + private static final Map UNESCAPE_MAP = new HashMap<>(); + private static final Pattern HTML_PATTERN = Pattern.compile("<[^>]*>"); + + static { + // HTML 特殊字符转义映射 + ESCAPE_MAP.put('&', "&"); + ESCAPE_MAP.put('<', "<"); + ESCAPE_MAP.put('>', ">"); + ESCAPE_MAP.put('"', """); + ESCAPE_MAP.put('\'', "'"); + + // 反向映射 + UNESCAPE_MAP.put("&", '&'); + UNESCAPE_MAP.put("<", '<'); + UNESCAPE_MAP.put(">", '>'); + UNESCAPE_MAP.put(""", '"'); + UNESCAPE_MAP.put("'", '\''); + } + + /** + * 转义 HTML 特殊字符 + * + * @param input 原始字符串 + * @return 转义后的字符串 + */ + public static String escape(String input) { + if (input == null || input.isEmpty()) { + return input; + } + + StringBuilder result = new StringBuilder(); + for (char c : input.toCharArray()) { + String escaped = ESCAPE_MAP.get(c); + if (escaped != null) { + result.append(escaped); + } else { + result.append(c); + } + } + return result.toString(); + } + + /** + * 反转义 HTML 特殊字符 + * + * @param input 转义后的字符串 + * @return 原始字符串 + */ + public static String unescape(String input) { + if (input == null || input.isEmpty()) { + return input; + } + + String result = input; + for (Map.Entry entry : UNESCAPE_MAP.entrySet()) { + result = result.replace(entry.getKey(), String.valueOf(entry.getValue())); + } + return result; + } + + /** + * 移除所有 HTML 标签 + * + * @param input 原始字符串 + * @return 移除标签后的字符串 + */ + public static String stripHtmlTags(String input) { + if (input == null || input.isEmpty()) { + return input; + } + return HTML_PATTERN.matcher(input).replaceAll(""); + } + + /** + * 安全转义(转义 + 移除标签) + * + * @param input 原始字符串 + * @return 安全字符串 + */ + public static String sanitize(String input) { + if (input == null || input.isEmpty()) { + return input; + } + // 先移除 HTML 标签,再转义特殊字符 + String noTags = stripHtmlTags(input); + return escape(noTags); + } + + /** + * 判断是否包含 HTML 标签 + * + * @param input 原始字符串 + * @return true-包含, false-不包含 + */ + public static boolean containsHtmlTags(String input) { + if (input == null || input.isEmpty()) { + return false; + } + return HTML_PATTERN.matcher(input).find(); + } +} \ No newline at end of file diff --git a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java index cc54fbd..ea4b245 100644 --- a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java +++ b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java @@ -47,6 +47,7 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactorymanage-audit manage-notify manage-file - + gym-member + diff --git a/gym-manage-web/pnpm-lock.yaml b/gym-manage-web/pnpm-lock.yaml index 63bd44e..cc01eea 100644 --- a/gym-manage-web/pnpm-lock.yaml +++ b/gym-manage-web/pnpm-lock.yaml @@ -59,7 +59,7 @@ importers: version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-vue': specifier: ^6.0.3 - version: 6.0.5(vite@7.3.1(@types/node@20.19.37))(vue@3.5.30(typescript@5.9.3)) + version: 6.0.5(vite@7.3.1(@types/node@20.19.37)(terser@5.46.2))(vue@3.5.30(typescript@5.9.3)) '@vitest/coverage-v8': specifier: ^4.1.1 version: 4.1.2(vitest@4.1.0) @@ -81,15 +81,18 @@ importers: prettier: specifier: ^3.1.1 version: 3.8.1 + terser: + specifier: ^5.46.1 + version: 5.46.2 typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@20.19.37) + version: 7.3.1(@types/node@20.19.37)(terser@5.46.2) vitest: specifier: ^4.0.16 - version: 4.1.0(@types/node@20.19.37)(@vitest/ui@4.1.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@20.19.37)) + version: 4.1.0(@types/node@20.19.37)(@vitest/ui@4.1.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@20.19.37)(terser@5.46.2)) vue-tsc: specifier: ^3.2.2 version: 3.2.5(typescript@5.9.3) @@ -390,10 +393,16 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -464,66 +473,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -858,6 +880,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -889,6 +914,9 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1631,6 +1659,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} @@ -1672,6 +1707,11 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + terser@5.46.2: + resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} + engines: {node: '>=10'} + hasBin: true + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -2138,8 +2178,18 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -2366,10 +2416,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@6.0.5(vite@7.3.1(@types/node@20.19.37))(vue@3.5.30(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.5(vite@7.3.1(@types/node@20.19.37)(terser@5.46.2))(vue@3.5.30(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 7.3.1(@types/node@20.19.37) + vite: 7.3.1(@types/node@20.19.37)(terser@5.46.2) vue: 3.5.30(typescript@5.9.3) '@vitest/coverage-v8@4.1.2(vitest@4.1.0)': @@ -2384,7 +2434,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.0(@types/node@20.19.37)(@vitest/ui@4.1.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@20.19.37)) + vitest: 4.1.0(@types/node@20.19.37)(@vitest/ui@4.1.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@20.19.37)(terser@5.46.2)) '@vitest/expect@4.1.0': dependencies: @@ -2395,13 +2445,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@20.19.37))': + '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@20.19.37)(terser@5.46.2))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@20.19.37) + vite: 7.3.1(@types/node@20.19.37)(terser@5.46.2) '@vitest/pretty-format@4.1.0': dependencies: @@ -2434,7 +2484,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.0(@types/node@20.19.37)(@vitest/ui@4.1.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@20.19.37)) + vitest: 4.1.0(@types/node@20.19.37)(@vitest/ui@4.1.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@20.19.37)(terser@5.46.2)) '@vitest/utils@4.1.0': dependencies: @@ -2642,6 +2692,8 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-from@1.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2668,6 +2720,8 @@ snapshots: commander@10.0.1: {} + commander@2.20.3: {} + concat-map@0.0.1: {} config-chain@1.1.13: @@ -3456,6 +3510,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + speakingurl@14.0.1: {} stackback@0.0.2: {} @@ -3494,6 +3555,13 @@ snapshots: symbol-tree@3.2.4: {} + terser@5.46.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + text-table@0.2.0: {} tinybench@2.9.0: {} @@ -3547,7 +3615,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@7.3.1(@types/node@20.19.37): + vite@7.3.1(@types/node@20.19.37)(terser@5.46.2): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) @@ -3558,11 +3626,12 @@ snapshots: optionalDependencies: '@types/node': 20.19.37 fsevents: 2.3.3 + terser: 5.46.2 - vitest@4.1.0(@types/node@20.19.37)(@vitest/ui@4.1.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@20.19.37)): + vitest@4.1.0(@types/node@20.19.37)(@vitest/ui@4.1.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@20.19.37)(terser@5.46.2)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@20.19.37)) + '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@20.19.37)(terser@5.46.2)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -3579,7 +3648,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@20.19.37) + vite: 7.3.1(@types/node@20.19.37)(terser@5.46.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.37