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/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/merge_comments.py b/gym-manage-api/gym-member/merge_comments.py new file mode 100644 index 0000000..3c1000c --- /dev/null +++ b/gym-manage-api/gym-member/merge_comments.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +import os +import re + +base_path = r"c:\Users\13603\Desktop\健身房项目\gym-manage\gym-manage-api\gym-member\src\main\java\cn\novalon\gym\manage\member" + +count = 0 +for root, dirs, files in os.walk(base_path): + for file in files: + if file.endswith('.java'): + filepath = os.path.join(root, file) + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # 匹配模式:两个连续的注释块(类文档 + 作者注释) + pattern = r'(/\*\*[\s\S]*?\*/)\s*\n(/\*\*\s*\n\s*\*\s*@author\s+.+\s*\n\s*\*\s*@date\s+\d{4}-\d{2}-\d{2}\s*\n\s*\*/)' + + match = re.search(pattern, content) + if match: + class_doc = match.group(1) + author_doc = match.group(2) + + # 提取作者信息 + author_match = re.search(r'@author\s+(.+)', author_doc) + date_match = re.search(r'@date\s+(\d{4}-\d{2}-\d{2})', author_doc) + + if author_match and date_match: + author = author_match.group(1).strip() + date = date_match.group(1).strip() + + # 提取类文档的内容(去掉 /** 和 */) + class_doc_content = re.sub(r'/\*\*\s*\n|\s*\*/', '', class_doc).strip() + # 清理每行开头的多余星号和空格 + class_doc_lines = class_doc_content.split('\n') + cleaned_lines = [] + for line in class_doc_lines: + cleaned_line = re.sub(r'^\s*\*\s?', '', line).strip() + if cleaned_line: + cleaned_lines.append(cleaned_line) + class_doc_content = '\n * '.join(cleaned_lines) + + # 构建合并后的注释 + merged_doc = f"""/** + * {class_doc_content} + * + * @author {author} + * @date {date} + */""" + + # 替换原文 + new_content = content[:match.start()] + merged_doc + content[match.end():] + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(new_content) + + count += 1 + print(f"已合并: {file}") + else: + # 检查是否有单独的作者注释在类文档之前 + pattern2 = r'/\*\*\s*\n\s*\*\s*@author\s+.+\s*\n\s*\*\s*@date\s+\d{4}-\d{2}-\d{2}\s*\n\s*\*/\s*\n(/\*\*[\s\S]*?\*/)' + match2 = re.search(pattern2, content) + if match2: + author_doc = match2.group(0).split('\n/**')[0] + '\n/**' + class_doc = '/**' + match2.group(1) + + # 提取作者信息 + author_match = re.search(r'@author\s+(.+)', author_doc) + date_match = re.search(r'@date\s+(\d{4}-\d{2}-\d{2})', author_doc) + + if author_match and date_match: + author = author_match.group(1).strip() + date = date_match.group(1).strip() + + # 提取类文档的内容 + class_doc_content = re.sub(r'/\*\*\s*\n|\s*\*/', '', class_doc).strip() + # 清理每行开头的多余星号和空格 + class_doc_lines = class_doc_content.split('\n') + cleaned_lines = [] + for line in class_doc_lines: + cleaned_line = re.sub(r'^\s*\*\s?', '', line).strip() + if cleaned_line: + cleaned_lines.append(cleaned_line) + class_doc_content = '\n * '.join(cleaned_lines) + + # 构建合并后的注释 + merged_doc = f"""/** + * {class_doc_content} + * + * @author {author} + * @date {date} + */""" + + # 替换原文 + new_content = content[:match2.start()] + merged_doc + content[match2.end():] + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(new_content) + + count += 1 + print(f"已合并: {file}") + +print(f"\n完成!共合并 {count} 个文件") diff --git a/gym-manage-api/gym-member/pom.xml b/gym-manage-api/gym-member/pom.xml new file mode 100644 index 0000000..00d2673 --- /dev/null +++ b/gym-manage-api/gym-member/pom.xml @@ -0,0 +1,246 @@ + + + 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} + + + 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 + + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + + + + + + + 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/spotbugs-exclude.xml b/gym-manage-api/gym-member/spotbugs-exclude.xml new file mode 100644 index 0000000..fd9cd91 --- /dev/null +++ b/gym-manage-api/gym-member/spotbugs-exclude.xml @@ -0,0 +1,18 @@ + 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/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..9527e6b --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/config/WechatProperties.java @@ -0,0 +1,66 @@ +package cn.novalon.gym.manage.member.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.PropertySource; +import org.springframework.stereotype.Component; + +/** + * 微信配置 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Data +@Component +@ConfigurationProperties(prefix = "wechat") +// 指定属性文件位置(当前在gym-member模块的application.yml) +@PropertySource(value = "classpath:application.yml", ignoreResourceNotFound = true) +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/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..1ad7597 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/BaseEntity.java @@ -0,0 +1,41 @@ +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; + + // 判断当前实体是否是新建的 + @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..36c7ba4 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/entity/Member.java @@ -0,0 +1,81 @@ +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; +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 Date 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/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/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..271d499 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java @@ -0,0 +1,140 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto; +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 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 +public class MemberHandler { + + private final MemberService memberService; + private final WechatAuthService wechatAuthService; + private final WechatOfficialService wechatOfficialService; + + /** + * 获取会员信息 + * + * GET /api/member/info + * Header: X-Member-Id: 123 + */ + public Mono getMemberInfo(ServerRequest request) { + + String memberIdStr = request.headers().firstHeader("X-Member-Id"); + long memberId = NumberUtils.toLong(memberIdStr,0L); + + if (memberId <= 0) throw new IllegalArgumentException("获取会员信息失败: memberId 无效"); + + log.info("获取会员信息, memberId: {}", memberId); + + return memberService.getMemberInfo(memberId) + .flatMap(info -> { + return ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(info); + }); + } + + /** + * 绑定手机号(微信小程序) + * + * POST /api/member/phone/bind?code=PHONE_CODE + * Header: X-Member-Id: 123 + */ + public Mono bindPhone(ServerRequest request) { + + String memberIdStr = request.headers().firstHeader("X-Member-Id"); + Long memberId = NumberUtils.toLong(memberIdStr, 0L); + + if (memberId <= 0) throw new IllegalArgumentException("绑定手机号失败: memberId 无效"); + + String phoneCode = request.queryParam("phoneCode").orElse(""); + + if (phoneCode == null || 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)); + } + + /** + * 查询服务号关注状态 + * + * GET /api/member/subscribe/status + * Header: X-Member-Id: 123 + * + */ + public Mono checkSubscribeStatus(ServerRequest request) { + + String memberIdStr = request.headers().firstHeader("X-Member-Id"); + long memberId = NumberUtils.toLong(memberIdStr,0L); + + if (memberId <= 0) throw new IllegalArgumentException("查询服务号关注状态失败: memberId 无效"); + + log.info("查询服务号关注状态, memberId: {}", memberId); + + return wechatOfficialService.checkSubscribeStatus(memberId) + .flatMap(subscribed -> { + return ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(subscribed); + }); + } + + /** + * 管理员更新手机号 + * + * POST /api/admin/members/123/phone + * Body: { "phone": "13800138000" } + * + */ + public Mono adminUpdatePhone(ServerRequest 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); + }); + } + +} 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..7ba7fa8 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/WechatAuthHandler.java @@ -0,0 +1,68 @@ +package cn.novalon.gym.manage.member.handler; + +import cn.novalon.gym.manage.member.dto.WechatLoginDto; +import cn.novalon.gym.manage.member.service.WechatAuthService; +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 +public class WechatAuthHandler { + + private final WechatAuthService wechatAuthService; + private final WechatOfficialEventHandler wechatOfficialEventHandler; + + /** + * 小程序更新 + * + * POST /api/member/auth/miniapp/login + * Body: {"code": "wx_login_code"} + * + * @param request ServerRequest + * @return Mono 登录响应 + */ + 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); + }); + } + + /** + * 更新ص + * + * POST /api/member/auth/mp/callback + * Body: subscribeopenid + * + */ + public Mono mpCallback(ServerRequest request) { + return wechatOfficialEventHandler.handleEvent(request); + } + + // ֤微信ŷǩ + 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..d35ef87 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/WechatOfficialEventHandler.java @@ -0,0 +1,218 @@ +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); + + // TODO: XML为WechatOfficialEventDto + // Ŀǰ򻯴ֱ获取openidevent + + 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("收到IJ:"); + 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. tokentimestampnonceֵ + 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/MemberRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberRepository.java new file mode 100644 index 0000000..1e248cf --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberRepository.java @@ -0,0 +1,28 @@ +package cn.novalon.gym.manage.member.repository; + +import cn.novalon.gym.manage.member.entity.Member; +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 MemberRepository extends R2dbcRepository { + + // UnionID查询会员 + Mono findByUnionId(String unionId); + + // 小程序OpenID查询会员 + Mono findByMiniappOpenId(String miniappOpenId); + + // 服务号OpenID查询会员 + Mono findByOfficialOpenId(String officialOpenId); + + // 手机号查询 + Mono findByPhone(String phone); +} 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/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..23a2ea8 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java @@ -0,0 +1,30 @@ +package cn.novalon.gym.manage.member.service; + +import cn.novalon.gym.manage.member.vo.MemberInfoVO; +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 phone 明文手机号 + * @return 是否成功 + */ + Mono adminUpdatePhone(Long memberId, String phone); +} 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/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..6509616 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java @@ -0,0 +1,117 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.member.vo.MemberInfoVO; +import cn.novalon.gym.manage.member.entity.Member; +import cn.novalon.gym.manage.member.repository.MemberRepository; +import cn.novalon.gym.manage.member.service.MemberService; +import cn.novalon.gym.manage.member.util.AesUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** + * 会员服务实现 + * + * @author 付嘉 + * @date 2026-05-01 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberServiceImpl implements MemberService { + + private final MemberRepository memberRepository; + + @Value("${wechat.aes.secret-key:}") + private String aesSecretKey; + + @Value("${wechat.aes.iv:}") + private String aesIv; + + @Override + public Mono getMemberInfo(Long memberId) { + return memberRepository.findById(memberId) + .map(this::buildMemberInfoResponse); + } + + // 会员信息响应 + private MemberInfoVO buildMemberInfoResponse(Member member) { + String phone = member.getPhone(); + String maskedPhone = phone != null ? phone.replace(phone.substring(3, 7), "****") : null; + + return MemberInfoVO.builder() + .id(member.getId()) + .nickname(member.getNickname()) + .phone(maskedPhone) + .gender(member.getGender()) + .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, aesSecretKey, aesIv); + log.info("手机号加密成功"); + } catch (Exception e) { + log.error("手机号加密失败", e); + return Mono.error(new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "手机号加密失败: " + e.getMessage() + )); + } + + return memberRepository.findByPhone(encryptedPhone) + .flatMap(existingMember -> { + if (existingMember.getId().equals(memberId)) { + log.warn("手机号已是当前用户的: memberId={}", memberId); + return Mono.error(new ResponseStatusException( + HttpStatus.CONFLICT, + "重复绑定" + )); + } else { + log.warn("手机号已被其他用户绑定: memberId={}, existingMemberId={}", + memberId, existingMember.getId()); + return Mono.error(new ResponseStatusException( + HttpStatus.CONFLICT, + "该手机号已被其他会员绑定" + )); + } + }) + .switchIfEmpty(Mono.defer(() -> { + log.info("手机号未被占用,可以绑定"); + return updateMemberPhone(memberId, encryptedPhone); + })); + } + + // 更新会员手机号 + private Mono updateMemberPhone(Long memberId, String encryptedPhone) { + return memberRepository.findById(memberId) + .flatMap(member -> { + member.setPhone(encryptedPhone); + member.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(member) + .map(savedMember -> { + log.info("手机号录入成功, memberId: {}", savedMember.getId()); + return true; + }); + }) + .switchIfEmpty(Mono.defer(() -> { + log.error("会员不存在: memberId={}", memberId); + return Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "会员不存在")); + })); + } +} 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..8b818f1 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatApiServiceImpl.java @@ -0,0 +1,260 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.member.config.WechatProperties; +import cn.novalon.gym.manage.member.service.WechatApiService; +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; + + /** + * WebClient实例 - 用于发送HTTP请求 + * 最大内存大小为10MB + */ + private final WebClient webClient = WebClient.builder() + .baseUrl("https://api.weixin.qq.com") + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) + .build(); + + /** + * 小程序 + * + * @param code 小程序登录的code + * @return Mono> session_keyopenidunionid响应格式 + */ + @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 + .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); + + // 解析JSON响应 + Map response; + try { + // 使用Jackson解析JSON响应 + 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 RuntimeException("微信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 RuntimeException("微信API失败 [" + errcode + "]: " + errmsg); + } + + // 获取session_keyopenidunionid + 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()); + } + return Mono.error(new RuntimeException("微信API响应异常 " + e.getMessage())); + }); + } + + /** + * 获取手机号 + * + * @param code 手机号或获取手机号的code + * @return Mono 手机号 + */ + @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 RuntimeException("获取手机号失败 " + errmsg); + } + }); + }) + // + .onErrorResume(e -> { + log.error("获取手机号失败", e); + return Mono.error(new RuntimeException("获取手机号失败 " + e.getMessage())); + }); + } + + /** + * 获取Access Token + * + * @param appType 应用类型 + * @return Mono access_token + */ + @Override + public Mono getAccessToken(String appType) { + log.debug("获取access_token, appType: {}", appType); + + // TODO: 实现缓存逻辑 + // 使用 Caffeine 或 Redis 缓存 + // 目前直接调用微信API + + 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) + .map(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); + + // TODO: 加入缓存 + // cache.put("wechat:token:" + appType, accessToken, expiresIn - 200, TimeUnit.SECONDS); + + return accessToken; + } else { + String errmsg = (String) response.get("errmsg"); + log.error("获取access_token失败: {}", errmsg); + throw new RuntimeException("获取access_token失败: " + errmsg); + } + }); + } + + /** + * 验证微信消息签名 + * + * @param signature 微信消息签名 + * @param timestamp 创建时间戳 + * @param nonce 随机字符串 + * @return boolean true-签名有效false-签名无效 + */ + @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..3d5fe99 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatAuthServiceImpl.java @@ -0,0 +1,369 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.common.config.JwtProperties; +import cn.novalon.gym.manage.member.config.WechatProperties; +import cn.novalon.gym.manage.member.dto.WechatLoginDto; +import cn.novalon.gym.manage.member.vo.WechatLoginVO; +import cn.novalon.gym.manage.member.entity.Member; +import cn.novalon.gym.manage.member.repository.MemberRepository; +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.MemberNoGenerator; +import cn.novalon.gym.manage.member.util.WechatPhoneUtil; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * 微信认证服务实现 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +@Service +@RequiredArgsConstructor +public class WechatAuthServiceImpl implements WechatAuthService { + + private final WechatApiService wechatApiService; + private final MemberRepository memberRepository; + private final JwtProperties jwtProperties; + private final WechatProperties wechatProperties; + private final WechatPhoneUtil wechatPhoneUtil; + + /** + * 小程序登录 - 通过微信 code 完成登录 + * + * @param request 微信登录请求 + * @return Mono 微信登录响应 + */ + @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) + .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) + .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) + .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) + .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); + return Mono.error(new RuntimeException("登录失败: " + e.getMessage())); + }); + } + + /** + * 绑定手机号 - 通过微信 code 获取并绑定手机号 + * + * @param memberId 会员 ID + * @param code 微信手机号 code + * @return Mono + */ + @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()); + return Mono.error(new RuntimeException("手机号已被其他会员绑定")); + } 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); + return Mono.error(new RuntimeException("绑定失败: " + e.getMessage())); + }); + } + + /** + * 更新会员手机号 + * + * @param memberId 会员 ID + * @param encryptedPhone 加密后的手机号(Base64 编码) + * @return Mono 是否更新成功 + */ + private Mono updateMemberPhone(Long memberId, String encryptedPhone) { + return memberRepository.findById(memberId) + .flatMap(member -> { + member.setPhone(encryptedPhone); + member.setLastLoginAt(LocalDateTime.now()); + return memberRepository.save(member) + .map(savedMember -> { + log.info("更新会员手机号成功, memberId: {}", savedMember.getId()); + return true; + }); + }) + .switchIfEmpty(Mono.defer(() -> { + log.error("会员不存在, memberId={}", memberId); + return Mono.error(new RuntimeException("会员不存在")); + })); + } + + /** + * 手机号加密 + * + * @param phoneNumber 明文手机号 + * @return 加密后的手机号(Base64 编码) + */ + private String encryptPhone(String phoneNumber) { + try { + String secretKey = wechatProperties.getPhoneEncryption().getSecretKey(); + String iv = wechatProperties.getPhoneEncryption().getIv(); + + String encryptedPhone = AesUtil.encrypt(phoneNumber, secretKey, iv); + + log.debug("手机号加密成功"); + return encryptedPhone; + } catch (Exception e) { + log.error("手机号加密失败", e); + throw new RuntimeException("手机号加密失败 " + e.getMessage()); + } + } + + /** + * AES 解密手机号 + * + * @param encryptedPhone 加密后的手机号(Base64 编码) + * @return 明文手机号 + */ + public String decryptPhone(String encryptedPhone) { + try { + String secretKey = wechatProperties.getPhoneEncryption().getSecretKey(); + String iv = wechatProperties.getPhoneEncryption().getIv(); + + String phoneNumber = AesUtil.decrypt(encryptedPhone, secretKey, iv); + + log.debug("手机号解密成功"); + return phoneNumber; + } catch (Exception e) { + log.error("手机号解密失败", e); + throw new RuntimeException("手机号解密失败 " + e.getMessage()); + } + } + + /** + * 创建新会员(首次登录) + * + * @param unionId 微信 UnionID + * @param openid 小程序 OpenID + * @param sessionKey 会话密钥 + * @param phoneCode 手机号 code(可选,前端调用 wx.getPhoneNumber()) + * @return Mono 登录响应 + */ + private Mono createNewMember(String unionId, String openid, String sessionKey, String phoneCode) { + log.info("开始创建新会员, unionId: {}, openid: {}", unionId, openid); + + // Step 1: 生成会员号 + String memberNo = MemberNoGenerator.generate(); + log.info("生成会员号: {}", memberNo); + + // Step 2: 构建 Member 实体(仅保存标识信息) + Member member = Member.builder() + .memberNo(memberNo) // 小程序注册时自动生成会员号 + .unionId(unionId) // 存储 UnionID 用于统一身份 + .miniappOpenId(openid) // 存储小程序 OpenID + .lastLoginAt(LocalDateTime.now()) // 记录首次登录时间 + .build(); + + log.info("用户未注册,创建新会员(仅保存标识信息)"); + + // Step 3: 保存 Member + return memberRepository.save(member) + .flatMap(savedMember -> { + log.info("保存 Member 成功, id: {}, memberNo: {}", savedMember.getId(), savedMember.getMemberNo()); + + // Step 4: 如果有 phoneCode,尝试获取手机号 + 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(m -> log.info("新用户手机号绑定成功")) + .thenReturn(buildLoginResponse(savedMember, true, sessionKey)); + } else { + log.warn("未获取到手机号"); + return Mono.just(buildLoginResponse(savedMember, true, sessionKey)); + } + }); + } else { + // 没有 phoneCode,直接返回 + return Mono.just(buildLoginResponse(savedMember, true, sessionKey)); + } + }); + } + + /** + * 构建登录响应 - 封装返回数据(前端调用) + * + * @param member 会员实体 + * @param isNewUser 是否为新用户 + * @param sessionKey 会话密钥(后续可用于解密) + * @return WechatLoginVO 登录响应 + */ + 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 ? "已绑定" : "未绑定"); + } + + // 生成 Access Token(使用 Gateway 相同的方式) + String accessToken = generateJwtToken(member.getId(), "access"); + // 生成 Refresh Token(有效期更长) + String refreshToken = generateJwtToken(member.getId(), "refresh", 7L * 24 * 60 * 60 * 1000); + + log.info("JWT Token 生成成功, memberId: {}", member.getId()); + + // 计算过期时间(秒) + long expiresIn = jwtProperties.getExpiration() / 1000; + + return WechatLoginVO.builder() + .memberId(member.getId()) + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn((int) expiresIn) + .isNewUser(isNewUser) + .needCompleteInfo(needCompleteInfo) + .build(); + } + + /** + * 生成 JWT Token(与 Gateway 一致) + * + * @param memberId 会员 ID + * @param tokenType Token 类型(access/refresh) + * @return JWT Token 字符串 + */ + private String generateJwtToken(Long memberId, String tokenType) { + return generateJwtToken(memberId, tokenType, jwtProperties.getExpiration()); + } + + /** + * 生成 JWT Token(自定义过期时间) + * + * @param memberId 会员 ID + * @param tokenType Token 类型(access/refresh) + * @param expirationMs 过期时间(毫秒) + * @return JWT Token 字符串 + */ + private String generateJwtToken(Long memberId, String tokenType, long expirationMs) { + SecretKey key = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expirationMs); + + Map claims = new HashMap<>(); + claims.put("memberId", memberId); + claims.put("tokenType", tokenType); + + return Jwts.builder() + .setClaims(claims) + .setSubject(String.valueOf(memberId)) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(key) + .compact(); + } +} 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..4e3a3d0 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatOfficialServiceImpl.java @@ -0,0 +1,313 @@ +package cn.novalon.gym.manage.member.service.impl; + +import cn.novalon.gym.manage.member.config.WechatProperties; +import cn.novalon.gym.manage.member.vo.WechatUserInfoVO; +import cn.novalon.gym.manage.member.entity.Member; +import cn.novalon.gym.manage.member.repository.MemberRepository; +import cn.novalon.gym.manage.member.service.WechatOfficialService; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +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 MemberRepository memberRepository; + private final WechatProperties wechatProperties; + private final WebClient webClient; + private final ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + /** + * 处理关注事件 + */ + @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(userInfo.getNickname()); + } + + if (existingMember.getAvatar() == null || existingMember.getAvatar().isEmpty()) { + existingMember.setAvatar(userInfo.getHeadimgurl()); + } + + return memberRepository.save(existingMember) + .then(sendWelcomeMessage(openId)); + } else { + log.info("老用户关注服务号: memberId={}", existingMember.getId()); + existingMember.setSubscribed(true); + existingMember.setLastLoginAt(LocalDateTime.now()); + + return memberRepository.save(existingMember) + .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) + .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) + .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); + }) + .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) + .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) { + return memberRepository.findById(memberId) + .map(member -> { + Boolean subscribed = member.getSubscribed(); + return subscribed != null && subscribed; + }) + .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(savedMember -> + log.info("从服务号创建新会员成功, memberId: {}", savedMember.getId())) + .then(); + } + + /** + * 获取微信AccessToken + * + * TODO: 应该使用缓存,避免频繁请求 + */ + private Mono getAccessToken() { + 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) + .map(response -> { + if (response.containsKey("errcode")) { + throw new RuntimeException("获取AccessToken失败: " + response.get("errmsg")); + } + return (String) response.get("access_token"); + }); + } + + /** + * 发送欢迎消息给用户 + */ + 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(); // 即使发送失败也不影响主流程 + }); + } +} 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..f54a003 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/util/AesUtil.java @@ -0,0 +1,77 @@ +package cn.novalon.gym.manage.member.util; + +import lombok.extern.slf4j.Slf4j; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * AES加密工具类 + * + * @author 付嘉 + * @date 2026-05-01 + */ + +@Slf4j +public class AesUtil { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; + + /** + * AES 解密 + * + * @param encryptedData 加密数据,Base64编码 + * @param key AES密钥,Base64编码(32字节) + * @param iv 初始化向量IV,Base64编码(16字节) + * @return 解密后的字符串 + */ + public static String decrypt(String encryptedData, String key, String iv) { + 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 原始数据 + * @param key AES密钥,Base64编码(32字节) + * @param iv 初始化向量IV,Base64编码(16字节) + * @return Base64编码的加密数据 + */ + public static String encrypt(String data, String key, String iv) { + 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/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/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..e4f3c4e --- /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 + */ + private 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/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..d8b60e4 --- /dev/null +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/vo/MemberInfoVO.java @@ -0,0 +1,46 @@ +package cn.novalon.gym.manage.member.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +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 Integer gender; + + // 生日 + private Date birthday; + + // 头像 + private String avatar; + + // 是否已绑定手机号 + private Boolean hasPhone; + + // 是否已关注公众号 + private Boolean isSubscribed; +} 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/application.yml b/gym-manage-api/gym-member/src/main/resources/application.yml new file mode 100644 index 0000000..789379f --- /dev/null +++ b/gym-manage-api/gym-member/src/main/resources/application.yml @@ -0,0 +1,18 @@ +# 微信配置(测试环境使用模拟数据) +wechat: + # Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境) + mock-enabled: false + miniapp: + app-id: wx4d480112b426100b + app-secret: 78548f0c0ff66c73d3e8b071897eb1e5 + mp: + app-id: wx6f138c9aacc8a0e8 + app-secret: 5df2e315e9268e96a43bb2cce1d2270b + token: test_token + aes-key: ${WECHAT_MP_AESKEY:test_aes_key} + # 服务器回调地址(微信服务器推送事件的URL) + callback-url: https://1me240209tk74.vicp.fun/api/member/auth/mp/callback + # 手机号加密配置 + phone-encryption: + secret-key: dGVzdF9zZWNyZXRfa2V5X2Zvcl9waG9uZV9lbmNyeXB0aW9uMTI= + iv: dGVzdF9pdl9mb3JfcGhvbmU= 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..ea35cdd --- /dev/null +++ b/gym-manage-api/gym-member/src/main/resources/db/schema.sql @@ -0,0 +1,138 @@ +-- ============================================ +-- 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 TIMESTAMP, -- 生日 + 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 '最后一次取消关注时间'; 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/gym-member/test-member-apis.bat b/gym-manage-api/gym-member/test-member-apis.bat new file mode 100644 index 0000000..97a6060 --- /dev/null +++ b/gym-manage-api/gym-member/test-member-apis.bat @@ -0,0 +1,56 @@ +@echo off +chcp 65001 >nul +echo ======================================== +echo 会员模块接口测试 +echo ======================================== +echo. + +set BASE_URL=http://localhost:8084 +set MEMBER_ID=26 + +echo [测试 1] 获取会员信息 +echo 请求: GET %BASE_URL%/api/member/info +curl -X GET "%BASE_URL%/api/member/info" -H "X-Member-Id: %MEMBER_ID%" -s | jq . +echo. +echo. + +echo [测试 2] 查询关注状态 +echo 请求: GET %BASE_URL%/api/member/subscribe/status +curl -X GET "%BASE_URL%/api/member/subscribe/status" -H "X-Member-Id: %MEMBER_ID%" -s +echo. +echo. + +echo [测试 3] 更新会员信息 +echo 请求: PUT %BASE_URL%/api/member/info?nickname=TestUser^&gender=1^&birthday=1990-01-01^&address=TestAddress +curl -X PUT "%BASE_URL%/api/member/info?nickname=TestUser&gender=1&birthday=1990-01-01&address=TestAddress" -H "X-Member-Id: %MEMBER_ID%" -s +echo. +echo. + +echo [测试 4] 管理端录入手机号 +echo 请求: POST %BASE_URL%/api/admin/members/%MEMBER_ID%/phone +curl -X POST "%BASE_URL%/api/admin/members/%MEMBER_ID%/phone" -H "Content-Type: application/json" -d "{\"phone\":\"13800138000\"}" -s +echo. +echo. + +echo [测试 5] 验证手机号已更新 +echo 请求: GET %BASE_URL%/api/member/info +curl -X GET "%BASE_URL%/api/member/info" -H "X-Member-Id: %MEMBER_ID%" -s | jq . +echo. +echo. + +echo [测试 6] 服务号回调验证签名 +echo 请求: GET %BASE_URL%/api/member/auth/mp/callback?signature=test^×tamp=123^&nonce=test^&echostr=test +curl -X GET "%BASE_URL%/api/member/auth/mp/callback?signature=test×tamp=123&nonce=test&echostr=test" -s +echo. +echo. + +echo ======================================== +echo 测试完成 +echo ======================================== +echo. +echo 提示: +echo 1. 小程序登录需要提供真实的微信code +echo 2. 服务号回调需要微信服务器推送的真实事件 +echo 3. 管理端录入手机号需要配置正确的AES密钥 +echo. +pause diff --git a/gym-manage-api/gym-member/test-member-apis.ps1 b/gym-manage-api/gym-member/test-member-apis.ps1 new file mode 100644 index 0000000..c4315ab --- /dev/null +++ b/gym-manage-api/gym-member/test-member-apis.ps1 @@ -0,0 +1,140 @@ +# 会员模块接口测试脚本 +# 使用前请确保应用已启动在 http://localhost:8084 + +$BASE_URL = "http://localhost:8084" +$TEST_MEMBER_ID = 26 # 测试环境会员ID + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " 会员模块接口测试" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# ======================================== +# 1. 测试获取会员信息 +# ======================================== +Write-Host "[测试 1] 获取会员信息" -ForegroundColor Yellow +Write-Host "请求: GET $BASE_URL/api/member/info" -ForegroundColor Gray +Write-Host "Header: X-Member-Id: $TEST_MEMBER_ID" -ForegroundColor Gray +try { + $response = Invoke-RestMethod -Uri "$BASE_URL/api/member/info" -Method Get -Headers @{ + "X-Member-Id" = "$TEST_MEMBER_ID" + } + Write-Host "✅ 成功" -ForegroundColor Green + Write-Host "响应:" -ForegroundColor Cyan + $response | ConvertTo-Json -Depth 10 +} catch { + Write-Host "❌ 失败: $($_.Exception.Message)" -ForegroundColor Red +} +Write-Host "" + +# ======================================== +# 2. 测试查询关注状态 +# ======================================== +Write-Host "[测试 2] 查询关注状态" -ForegroundColor Yellow +Write-Host "请求: GET $BASE_URL/api/member/subscribe/status" -ForegroundColor Gray +Write-Host "Header: X-Member-Id: $TEST_MEMBER_ID" -ForegroundColor Gray +try { + $response = Invoke-RestMethod -Uri "$BASE_URL/api/member/subscribe/status" -Method Get -Headers @{ + "X-Member-Id" = "$TEST_MEMBER_ID" + } + Write-Host "✅ 成功" -ForegroundColor Green + Write-Host "响应: $response" -ForegroundColor Cyan +} catch { + Write-Host "❌ 失败: $($_.Exception.Message)" -ForegroundColor Red +} +Write-Host "" + +# ======================================== +# 3. 测试更新会员信息 +# ======================================== +Write-Host "[测试 3] 更新会员信息" -ForegroundColor Yellow +Write-Host "请求: PUT $BASE_URL/api/member/info?nickname=测试用户&gender=1&birthday=1990-01-01&address=测试地址" -ForegroundColor Gray +Write-Host "Header: X-Member-Id: $TEST_MEMBER_ID" -ForegroundColor Gray +try { + $response = Invoke-RestMethod -Uri "$BASE_URL/api/member/info?nickname=测试用户&gender=1&birthday=1990-01-01&address=测试地址" -Method Put -Headers @{ + "X-Member-Id" = "$TEST_MEMBER_ID" + } + Write-Host "✅ 成功" -ForegroundColor Green + Write-Host "响应: $response" -ForegroundColor Cyan +} catch { + Write-Host "❌ 失败: $($_.Exception.Message)" -ForegroundColor Red +} +Write-Host "" + +# ======================================== +# 4. 测试管理端录入手机号 +# ======================================== +Write-Host "[测试 4] 管理端录入手机号" -ForegroundColor Yellow +Write-Host "请求: POST $BASE_URL/api/admin/members/$TEST_MEMBER_ID/phone" -ForegroundColor Gray +Write-Host "Body: { `"phone`": `"13800138000`" }" -ForegroundColor Gray +try { + $body = @{ phone = "13800138000" } | ConvertTo-Json + $response = Invoke-RestMethod -Uri "$BASE_URL/api/admin/members/$TEST_MEMBER_ID/phone" -Method Post -Body $body -ContentType "application/json" + Write-Host "✅ 成功" -ForegroundColor Green + Write-Host "响应: $response" -ForegroundColor Cyan +} catch { + Write-Host "❌ 失败: $($_.Exception.Message)" -ForegroundColor Red +} +Write-Host "" + +# ======================================== +# 5. 再次获取会员信息(验证手机号已更新) +# ======================================== +Write-Host "[测试 5] 验证手机号已更新" -ForegroundColor Yellow +Write-Host "请求: GET $BASE_URL/api/member/info" -ForegroundColor Gray +Write-Host "Header: X-Member-Id: $TEST_MEMBER_ID" -ForegroundColor Gray +try { + $response = Invoke-RestMethod -Uri "$BASE_URL/api/member/info" -Method Get -Headers @{ + "X-Member-Id" = "$TEST_MEMBER_ID" + } + Write-Host "✅ 成功" -ForegroundColor Green + Write-Host "响应:" -ForegroundColor Cyan + $response | ConvertTo-Json -Depth 10 + + if ($response.hasPhone) { + Write-Host "✅ 手机号已成功绑定" -ForegroundColor Green + } else { + Write-Host "⚠️ 手机号未绑定" -ForegroundColor Yellow + } +} catch { + Write-Host "❌ 失败: $($_.Exception.Message)" -ForegroundColor Red +} +Write-Host "" + +# ======================================== +# 6. 测试小程序登录(需要有效的code) +# ======================================== +Write-Host "[测试 6] 小程序登录(需要提供有效的微信code)" -ForegroundColor Yellow +Write-Host "注意: 此测试需要从小程序获取真实的code,跳过自动测试" -ForegroundColor Gray +Write-Host "手动测试命令:" -ForegroundColor Cyan +Write-Host "curl -X POST $BASE_URL/api/member/auth/miniapp/login \" -ForegroundColor Gray +Write-Host " -H `"Content-Type: application/json`" \" -ForegroundColor Gray +Write-Host " -d `"{`"code`":`"YOUR_CODE_HERE`"}"`" -ForegroundColor Gray +Write-Host "" + +# ======================================== +# 7. 测试服务号回调验证签名 +# ======================================== +Write-Host "[测试 7] 服务号回调验证签名" -ForegroundColor Yellow +Write-Host "请求: GET $BASE_URL/api/member/auth/mp/callback?signature=test×tamp=123&nonce=test&echostr=test" -ForegroundColor Gray +try { + $response = Invoke-RestMethod -Uri "$BASE_URL/api/member/auth/mp/callback?signature=test×tamp=123&nonce=test&echostr=test" -Method Get + Write-Host "✅ 成功(返回echostr表示验证通过)" -ForegroundColor Green + Write-Host "响应: $response" -ForegroundColor Cyan +} catch { + Write-Host "❌ 失败: $($_.Exception.Message)" -ForegroundColor Red +} +Write-Host "" + +# ======================================== +# 总结 +# ======================================== +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " 测试完成" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "提示:" -ForegroundColor Yellow +Write-Host "1. 小程序登录需要提供真实的微信code" -ForegroundColor Gray +Write-Host "2. 服务号回调需要微信服务器推送的真实事件" -ForegroundColor Gray +Write-Host "3. 管理端录入手机号需要配置正确的AES密钥" -ForegroundColor Gray +Write-Host ""