Sfoglia il codice sorgente

登录功能实现

Zhangbw 3 mesi fa
parent
commit
d317dc7628

+ 117 - 0
LOGIN_FLOW.md

@@ -0,0 +1,117 @@
+# 微信小程序登录流程说明
+
+## 概述
+
+本系统采用**三步渐进式登录流程**,使用**旧版手机号解密方式**(不需要开通"手机号快速验证组件"权限)。
+
+## 登录流程
+
+### 第一步:微信静默登录(老用户)
+
+**目的**:检查用户是否已注册,老用户可直接登录
+
+**流程**:
+1. 前端调用 `wx.login()` 获取 `code`
+2. 发送到后端:`POST /auth/sys/miniapp/custom/openid`
+3. 后端用 `code` 调用微信API获取 `openid`
+4. 根据 `openid` 查询数据库
+5. 返回结果:
+   - 老用户:`{ isSign: "true", token }`
+   - 新用户:`{ isSign: "false" }`
+
+### 第二步:手机号授权(新用户)
+
+**目的**:获取用户手机号,检查是否已用手机号注册
+
+**流程**:
+1. 用户点击授权按钮(`open-type="getPhoneNumber"`)
+2. 微信弹窗授权,获取 `encryptedData` 和 `iv`
+3. 重新调用 `wx.login()` 获取新的 `code`
+4. 发送到后端:`POST /auth/sys/miniapp/custom/check`
+   ```json
+   {
+     "loginCode": "新的code",
+     "encryptedData": "加密的手机号数据",
+     "iv": "加密算法初始向量"
+   }
+   ```
+5. 后端用 `code` 获取 `session_key`
+6. 后端用 `session_key` 解密 `encryptedData` 得到手机号
+7. 根据手机号查询数据库
+8. 返回结果:
+   - 已注册:`{ isSign: "true", token }`
+   - 未注册:`{ isSign: "false", openid, unionid, phoneNumber }`
+
+### 第三步:完善用户信息(首次登录)
+
+**目的**:收集用户昵称和头像,创建新用户
+
+**流程**:
+1. 弹出用户信息弹窗
+2. 用户输入昵称、选择头像
+3. 上传头像到服务器
+4. 发送到后端:`POST /auth/sys/miniapp/custom/login`
+   ```json
+   {
+     "openid": "用户openid",
+     "unionid": "用户unionid",
+     "phoneNumber": "手机号",
+     "nickname": "昵称",
+     "avatarUrl": "头像URL"
+   }
+   ```
+5. 后端创建新用户记录
+6. 返回:`{ token }`
+
+## 技术要点
+
+### 1. 手机号解密(AES-128-CBC)
+
+```java
+// 使用 session_key 作为密钥
+// 使用 iv 作为初始向量
+// 使用 AES/CBC/PKCS5Padding 算法解密
+byte[] decryptedBytes = cipher.doFinal(encryptedDataBytes);
+String decryptedData = new String(decryptedBytes, "UTF-8");
+// 解析JSON获取 purePhoneNumber
+```
+
+### 2. 为什么需要两次 wx.login()?
+
+- 第一次:获取 `openid` 检查是否为老用户
+- 第二次:获取新的 `session_key` 用于解密手机号
+- 原因:微信的 `code` 是一次性的,用过就失效
+
+### 3. Token 机制
+
+- 使用 JWT 生成 token
+- 包含 `userId` 和 `phone`
+- 有效期 7 天
+- 前端存储在 `localStorage`
+- 后续请求通过 `Authorization: Bearer {token}` 携带
+
+## 接口列表
+
+| 接口 | 方法 | 说明 |
+|------|------|------|
+| `/auth/sys/miniapp/custom/openid` | POST | 第一步:静默登录 |
+| `/auth/sys/miniapp/custom/check` | POST | 第二步:手机号验证 |
+| `/auth/sys/miniapp/custom/login` | POST | 第三步:完善信息 |
+| `/wd/miniapp-member/bizBigMember/getMemberInfoByToken` | GET | 获取用户信息 |
+| `/jeecg-boot/mg/sys/oss/file/upload` | POST | 上传头像 |
+
+## 优势
+
+1. **老用户体验好**:无需重复授权,静默登录
+2. **新用户流程简单**:一键授权手机号
+3. **安全性高**:手机号加密传输,前端无法获取明文
+4. **无需付费权限**:使用旧版解密方式,不需要开通"手机号快速验证组件"
+5. **个人小程序可用**:不受企业认证限制
+
+## 注意事项
+
+1. `encryptedData` 和 `iv` 必须同时存在
+2. `session_key` 有效期为用户会话期间
+3. 手机号解密后格式为:`13800138000`(纯数字,无国家码)
+4. 头像上传需要先获取 token
+5. 用户信息弹窗不允许点击遮罩关闭

+ 25 - 6
src/main/java/com/yingpai/gupiao/config/WebMvcConfig.java

@@ -2,8 +2,10 @@ package com.yingpai.gupiao.config;
 
 import com.yingpai.gupiao.interceptor.AuthInterceptor;
 import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
 /**
@@ -16,6 +18,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
     
     private final AuthInterceptor authInterceptor;
     
+    @Value("${file.upload.path:/uploads}")
+    private String uploadPath;
+    
     /**
      * 添加拦截器配置
      * @param registry 拦截器注册器
@@ -27,12 +32,26 @@ public class WebMvcConfig implements WebMvcConfigurer {
                 .addPathPatterns("/**")
                 // 排除不需要登录的接口
                 .excludePathPatterns(
-                        "/v1/auth/**",           // 认证相关接口
-                        "/v1/stock/suggestion",  // 股票搜索建议
-                        "/v1/stock/search",      // 股票搜索
-                        "/error",                // 错误页面
-                        "/favicon.ico",          // 浏览器图标
-                        "/static/**"             // 静态资源
+                        "/v1/auth/**",                                  // 认证相关接口
+                        "/auth/sys/miniapp/custom/**",                  // 微信小程序登录接口
+                        "/jeecg-boot/mg/sys/oss/file/upload",          // 文件上传接口
+                        "/v1/stock/suggestion",                         // 股票搜索建议
+                        "/v1/stock/search",                             // 股票搜索
+                        "/uploads/**",                                  // 上传文件访问
+                        "/error",                                       // 错误页面
+                        "/favicon.ico",                                 // 浏览器图标
+                        "/static/**"                                    // 静态资源
                 );
     }
+    
+    /**
+     * 配置静态资源映射
+     * 将 /uploads/** 映射到文件上传目录
+     */
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        // 配置上传文件的访问路径
+        registry.addResourceHandler("/uploads/**")
+                .addResourceLocations("file:" + uploadPath + "/");
+    }
 }

+ 60 - 41
src/main/java/com/yingpai/gupiao/controller/AuthController.java

@@ -1,8 +1,9 @@
 package com.yingpai.gupiao.controller;
 
-import com.yingpai.gupiao.domain.dto.LoginDTO;
+import com.yingpai.gupiao.domain.dto.*;
 import com.yingpai.gupiao.domain.vo.LoginVO;
 import com.yingpai.gupiao.domain.vo.Result;
+import com.yingpai.gupiao.domain.vo.WxLoginCheckVO;
 import com.yingpai.gupiao.service.AuthService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -11,86 +12,104 @@ import org.springframework.web.bind.annotation.*;
 /**
  * 认证控制器
  * 处理用户登录、注册、验证码等请求
+ * 
+ * 完整登录流程:
+ * 1. /auth/sys/miniapp/custom/openid - 检查是否为老用户(静默登录)
+ * 2. /auth/sys/miniapp/custom/check - 验证手机号(新用户)
+ * 3. /auth/sys/miniapp/custom/login - 完善用户信息(首次登录)
  */
 @Slf4j
 @RestController
-@RequestMapping("/v1/auth")
 @RequiredArgsConstructor
+@RequestMapping("/auth/sys/miniapp")
 public class AuthController {
     
     private final AuthService authService;
     
     /**
-     * 微信登录接口
-     * @param loginDTO 登录请求参数,包含微信code
-     * @return 登录结果,包含token和用户信息
+     * 第一步:微信静默登录(检查是否为老用户)
+     * 接口路径:/auth/sys/miniapp/custom/openid
+     * 
+     * @param dto 包含 loginCode
+     * @return 老用户返回 { isSign: "true", token },新用户返回 { isSign: "false" }
      */
-    @PostMapping("/wxLogin")
-    public Result<LoginVO> wxLogin(@RequestBody LoginDTO loginDTO) {
-        log.info("微信登录请求,code: {}", loginDTO.getCode());
+    @PostMapping("/custom/openid")
+    public Result<WxLoginCheckVO> wxSilentLogin(@RequestBody WxSilentLoginDTO dto) {
+        log.info("【第一步】微信静默登录,loginCode: {}", dto.getLoginCode());
         
-        if (loginDTO.getCode() == null || loginDTO.getCode().isEmpty()) {
+        if (dto.getLoginCode() == null || dto.getLoginCode().isEmpty()) {
             return Result.error("登录code不能为空");
         }
         
         try {
-            LoginVO loginVO = authService.wxLogin(loginDTO.getCode());
-            return Result.success(loginVO);
+            WxLoginCheckVO result = authService.wxSilentLogin(dto.getLoginCode());
+            return Result.success(result);
         } catch (Exception e) {
-            log.error("微信登录失败", e);
+            log.error("微信静默登录失败", e);
             return Result.error("登录失败:" + e.getMessage());
         }
     }
     
     /**
-     * 手机号验证码登录接口
-     * @param loginDTO 登录请求参数,包含手机号和验证码
-     * @return 登录结果,包含token和用户信息
+     * 第二步:手机号授权验证(新用户)
+     * 接口路径:/auth/sys/miniapp/custom/check
+     * 
+     * @param dto 包含 loginCode, phoneCode, encryptedData, iv
+     * @return 已注册返回 { isSign: "true", token },未注册返回 { isSign: "false", openid, unionid, phoneNumber }
      */
-    @PostMapping("/phoneLogin")
-    public Result<LoginVO> phoneLogin(@RequestBody LoginDTO loginDTO) {
-        log.info("手机号登录请求,phone: {}, verifyCode: {}", loginDTO.getPhone(), loginDTO.getVerifyCode());
+    @PostMapping("/custom/check")
+    public Result<WxLoginCheckVO> wxPhoneCheck(@RequestBody WxPhoneLoginDTO dto) {
+        log.info("【第二步】手机号授权验证,loginCode: {}, phoneCode: {}", 
+                dto.getLoginCode(), dto.getPhoneCode());
         
-        if (loginDTO.getPhone() == null || loginDTO.getPhone().isEmpty()) {
-            return Result.error("手机号不能为空");
+        if (dto.getLoginCode() == null || dto.getLoginCode().isEmpty()) {
+            return Result.error("登录code不能为空");
         }
         
-        if (loginDTO.getVerifyCode() == null || loginDTO.getVerifyCode().isEmpty()) {
-            return Result.error("验证码不能为空");
+        if (dto.getPhoneCode() == null || dto.getPhoneCode().isEmpty()) {
+            return Result.error("手机号授权code不能为空");
         }
         
         try {
-            LoginVO loginVO = authService.phoneLogin(loginDTO.getPhone(), loginDTO.getVerifyCode());
-            return Result.success(loginVO);
+            WxLoginCheckVO result = authService.wxPhoneCheck(dto);
+            return Result.success(result);
         } catch (Exception e) {
-            log.error("手机号登录失败", e);
-            return Result.error("登录失败:" + e.getMessage());
+            log.error("手机号授权验证失败", e);
+            return Result.error("验证失败:" + e.getMessage());
         }
     }
     
     /**
-     * 发送短信验证码接口
-     * @param loginDTO 请求参数,包含手机号
-     * @return 发送结果
+     * 第三步:完善用户信息(首次登录)
+     * 接口路径:/auth/sys/miniapp/custom/login
+     * 
+     * @param dto 包含 openid, unionid, phoneNumber, nickname, avatarUrl
+     * @return 返回 { token }
      */
-    @PostMapping("/sendCode")
-    public Result<Void> sendCode(@RequestBody LoginDTO loginDTO) {
-        log.info("发送验证码请求,phone: {}", loginDTO.getPhone());
+    @PostMapping("/custom/login")
+    public Result<LoginVO> wxCompleteUserInfo(@RequestBody WxCompleteUserInfoDTO dto) {
+        log.info("【第三步】完善用户信息,openid: {}, phone: {}, nickname: {}", 
+                dto.getOpenid(), dto.getPhoneNumber(), dto.getNickname());
+        
+        if (dto.getOpenid() == null || dto.getOpenid().isEmpty()) {
+            return Result.error("openid不能为空");
+        }
         
-        if (loginDTO.getPhone() == null || loginDTO.getPhone().isEmpty()) {
+        if (dto.getPhoneNumber() == null || dto.getPhoneNumber().isEmpty()) {
             return Result.error("手机号不能为空");
         }
         
+        if (dto.getNickname() == null || dto.getNickname().isEmpty()) {
+            return Result.error("昵称不能为空");
+        }
+        
         try {
-            boolean success = authService.sendSmsCode(loginDTO.getPhone());
-            if (success) {
-                return Result.success();
-            } else {
-                return Result.error("验证码发送失败");
-            }
+            LoginVO result = authService.wxCompleteUserInfo(dto);
+            return Result.success(result);
         } catch (Exception e) {
-            log.error("发送验证码失败", e);
-            return Result.error("发送失败:" + e.getMessage());
+            log.error("完善用户信息失败", e);
+            return Result.error("注册失败:" + e.getMessage());
         }
     }
+
 }

+ 160 - 0
src/main/java/com/yingpai/gupiao/controller/FileUploadController.java

@@ -0,0 +1,160 @@
+package com.yingpai.gupiao.controller;
+
+import com.yingpai.gupiao.domain.vo.Result;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 文件上传控制器
+ * 处理文件上传请求(头像、图片等)
+ */
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class FileUploadController {
+    
+    /**
+     * 文件上传根目录(从配置文件读取)
+     */
+    @Value("${file.upload.path:/uploads}")
+    private String uploadPath;
+    
+    /**
+     * 文件访问URL前缀(从配置文件读取)
+     */
+    @Value("${file.access.url:http://localhost:8080}")
+    private String accessUrl;
+    
+    /**
+     * 允许的图片格式
+     */
+    private static final String[] ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"};
+    
+    /**
+     * 最大文件大小(5MB)
+     */
+    private static final long MAX_FILE_SIZE = 5 * 1024 * 1024;
+    
+    /**
+     * 文件上传接口
+     * 接口路径:/jeecg-boot/mg/sys/oss/file/upload
+     * 
+     * @param file 上传的文件
+     * @return 文件访问URL
+     */
+    @PostMapping("/jeecg-boot/mg/sys/oss/file/upload")
+    public Result<Map<String, String>> uploadFile(@RequestParam("file") MultipartFile file) {
+        log.info("文件上传请求,文件名: {}, 大小: {} bytes", file.getOriginalFilename(), file.getSize());
+        
+        try {
+            // 1. 验证文件
+            validateFile(file);
+            
+            // 2. 生成文件名和路径
+            String originalFilename = file.getOriginalFilename();
+            String extension = getFileExtension(originalFilename);
+            String newFilename = UUID.randomUUID().toString() + extension;
+            
+            // 按日期分目录存储:/uploads/2024/01/15/xxx.jpg
+            String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+            String relativePath = dateDir + "/" + newFilename;
+            
+            // 3. 创建目录
+            Path uploadDir = Paths.get(uploadPath, dateDir);
+            if (!Files.exists(uploadDir)) {
+                Files.createDirectories(uploadDir);
+                log.info("创建上传目录: {}", uploadDir);
+            }
+            
+            // 4. 保存文件
+            Path filePath = uploadDir.resolve(newFilename);
+            file.transferTo(filePath.toFile());
+            log.info("文件保存成功: {}", filePath);
+            
+            // 5. 构建访问URL
+            String fileUrl = accessUrl + "/uploads/" + relativePath;
+            
+            // 6. 返回结果
+            Map<String, String> result = new HashMap<>();
+            result.put("url", fileUrl);
+            result.put("filename", newFilename);
+            result.put("originalFilename", originalFilename);
+            
+            log.info("文件上传成功,访问URL: {}", fileUrl);
+            return Result.success(result);
+            
+        } catch (IllegalArgumentException e) {
+            log.error("文件验证失败", e);
+            return Result.error(e.getMessage());
+        } catch (IOException e) {
+            log.error("文件保存失败", e);
+            return Result.error("文件上传失败:" + e.getMessage());
+        } catch (Exception e) {
+            log.error("文件上传异常", e);
+            return Result.error("文件上传失败:" + e.getMessage());
+        }
+    }
+    
+    /**
+     * 验证文件
+     */
+    private void validateFile(MultipartFile file) {
+        // 检查文件是否为空
+        if (file == null || file.isEmpty()) {
+            throw new IllegalArgumentException("文件不能为空");
+        }
+        
+        // 检查文件大小
+        if (file.getSize() > MAX_FILE_SIZE) {
+            throw new IllegalArgumentException("文件大小不能超过5MB");
+        }
+        
+        // 检查文件扩展名
+        String originalFilename = file.getOriginalFilename();
+        if (originalFilename == null || originalFilename.isEmpty()) {
+            throw new IllegalArgumentException("文件名不能为空");
+        }
+        
+        String extension = getFileExtension(originalFilename).toLowerCase();
+        boolean isAllowed = false;
+        for (String allowedExt : ALLOWED_EXTENSIONS) {
+            if (extension.equals(allowedExt)) {
+                isAllowed = true;
+                break;
+            }
+        }
+        
+        if (!isAllowed) {
+            throw new IllegalArgumentException("不支持的文件格式,仅支持:jpg、jpeg、png、gif、webp");
+        }
+    }
+    
+    /**
+     * 获取文件扩展名
+     */
+    private String getFileExtension(String filename) {
+        if (filename == null || filename.isEmpty()) {
+            return "";
+        }
+        
+        int lastDotIndex = filename.lastIndexOf('.');
+        if (lastDotIndex == -1) {
+            return "";
+        }
+        
+        return filename.substring(lastDotIndex);
+    }
+}

+ 0 - 1
src/main/java/com/yingpai/gupiao/controller/StockSearchController.java

@@ -3,7 +3,6 @@ package com.yingpai.gupiao.controller;
 import com.yingpai.gupiao.domain.dto.StockSearchRequest;
 import com.yingpai.gupiao.domain.dto.StockSuggestionDto;
 import com.yingpai.gupiao.domain.vo.ApiResponse;
-import com.yingpai.gupiao.domain.vo.PageResult;
 import com.yingpai.gupiao.domain.vo.StockDetailResponse;
 import com.yingpai.gupiao.domain.vo.StockSearchResponseItem;
 import com.yingpai.gupiao.service.StockSearchService;

+ 27 - 4
src/main/java/com/yingpai/gupiao/controller/UserController.java

@@ -15,7 +15,6 @@ import org.springframework.web.bind.annotation.*;
  */
 @Slf4j
 @RestController
-@RequestMapping("/v1/user")
 @RequiredArgsConstructor
 public class UserController {
     
@@ -23,11 +22,35 @@ public class UserController {
     private final JwtUtil jwtUtil;
     
     /**
-     * 获取用户信息
+     * 第四步:获取用户完整信息(通过token)
+     * 接口路径:/wd/miniapp-member/bizBigMember/getMemberInfoByToken
+     * 
+     * @param authorization 请求头中的token
+     * @return 用户完整信息
+     */
+    @GetMapping("/wd/miniapp-member/bizBigMember/getMemberInfoByToken")
+    public Result<LoginVO.UserInfoVO> getMemberInfoByToken(@RequestHeader("Authorization") String authorization) {
+        try {
+            // 从token中获取用户ID
+            String token = authorization.replace("Bearer ", "");
+            Long userId = jwtUtil.getUserIdFromToken(token);
+            
+            log.info("【第四步】获取用户完整信息,userId: {}", userId);
+            
+            LoginVO.UserInfoVO userInfo = userService.getUserInfo(userId);
+            return Result.success(userInfo);
+        } catch (Exception e) {
+            log.error("获取用户信息失败", e);
+            return Result.error("获取用户信息失败:" + e.getMessage());
+        }
+    }
+    
+    /**
+     * 获取用户信息(原有接口)
      * @param authorization 请求头中的token
      * @return 用户信息
      */
-    @GetMapping("/info")
+    @GetMapping("/v1/user/info")
     public Result<LoginVO.UserInfoVO> getUserInfo(@RequestHeader("Authorization") String authorization) {
         try {
             // 从token中获取用户ID
@@ -50,7 +73,7 @@ public class UserController {
      * @param updateProfileDTO 更新资料DTO
      * @return 更新后的用户信息
      */
-    @PutMapping("/profile")
+    @PutMapping("/v1/user/profile")
     public Result<LoginVO.UserInfoVO> updateProfile(
             @RequestHeader("Authorization") String authorization,
             @RequestBody UpdateProfileDTO updateProfileDTO) {

+ 5 - 0
src/main/java/com/yingpai/gupiao/domain/dto/LoginDTO.java

@@ -24,6 +24,11 @@ public class LoginDTO {
      */
     private String avatar;
     
+    /**
+     * 手机号授权code(微信手机号授权时使用)
+     */
+    private String phoneCode;
+    
     /**
      * 手机号(手机号登录时使用)
      */

+ 35 - 0
src/main/java/com/yingpai/gupiao/domain/dto/WxCompleteUserInfoDTO.java

@@ -0,0 +1,35 @@
+package com.yingpai.gupiao.domain.dto;
+
+import lombok.Data;
+
+/**
+ * 完善用户信息请求DTO
+ * 首次登录完善信息
+ */
+@Data
+public class WxCompleteUserInfoDTO {
+    /**
+     * 微信openid
+     */
+    private String openid;
+    
+    /**
+     * 微信unionid
+     */
+    private String unionid;
+    
+    /**
+     * 手机号
+     */
+    private String phoneNumber;
+    
+    /**
+     * 昵称
+     */
+    private String nickname;
+    
+    /**
+     * 头像URL
+     */
+    private String avatarUrl;
+}

+ 30 - 0
src/main/java/com/yingpai/gupiao/domain/dto/WxPhoneLoginDTO.java

@@ -0,0 +1,30 @@
+package com.yingpai.gupiao.domain.dto;
+
+import lombok.Data;
+
+/**
+ * 微信手机号授权登录请求DTO
+ * 验证手机号(使用encryptedData解密方式)
+ */
+@Data
+public class WxPhoneLoginDTO {
+    /**
+     * 微信登录code(重新获取的)
+     */
+    private String loginCode;
+    
+    /**
+     * 手机号授权code(新版方式,暂不使用)
+     */
+    private String phoneCode;
+    
+    /**
+     * 加密数据(旧版方式,当前使用)
+     */
+    private String encryptedData;
+    
+    /**
+     * 加密算法初始向量(旧版方式,当前使用)
+     */
+    private String iv;
+}

+ 73 - 0
src/main/java/com/yingpai/gupiao/domain/dto/WxPhoneResponse.java

@@ -0,0 +1,73 @@
+package com.yingpai.gupiao.domain.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * 微信手机号响应DTO
+ * 对应微信API返回的手机号数据结构
+ */
+@Data
+public class WxPhoneResponse {
+    
+    /**
+     * 错误码
+     */
+    private Integer errcode;
+    
+    /**
+     * 错误信息
+     */
+    private String errmsg;
+    
+    /**
+     * 手机号信息
+     */
+    @JsonProperty("phone_info")
+    private PhoneInfo phoneInfo;
+    
+    /**
+     * 手机号信息内部类
+     */
+    @Data
+    public static class PhoneInfo {
+        /**
+         * 用户绑定的手机号(国外手机号会有区号)
+         */
+        @JsonProperty("phoneNumber")
+        private String phoneNumber;
+        
+        /**
+         * 没有区号的手机号
+         */
+        @JsonProperty("purePhoneNumber")
+        private String purePhoneNumber;
+        
+        /**
+         * 区号
+         */
+        @JsonProperty("countryCode")
+        private String countryCode;
+        
+        /**
+         * 数据水印
+         */
+        private Watermark watermark;
+    }
+    
+    /**
+     * 数据水印内部类
+     */
+    @Data
+    public static class Watermark {
+        /**
+         * 小程序appid
+         */
+        private String appid;
+        
+        /**
+         * 时间戳
+         */
+        private Long timestamp;
+    }
+}

+ 15 - 0
src/main/java/com/yingpai/gupiao/domain/dto/WxSilentLoginDTO.java

@@ -0,0 +1,15 @@
+package com.yingpai.gupiao.domain.dto;
+
+import lombok.Data;
+
+/**
+ * 微信静默登录请求DTO
+ * 检查是否为老用户
+ */
+@Data
+public class WxSilentLoginDTO {
+    /**
+     * 微信登录code
+     */
+    private String loginCode;
+}

+ 0 - 58
src/main/java/com/yingpai/gupiao/domain/vo/PageResult.java

@@ -1,58 +0,0 @@
-package com.yingpai.gupiao.domain.vo;
-
-import java.util.List;
-
-/**
- * data 字段中分页结构
- */
-public class PageResult<T> {
-
-    private List<T> list;
-    private int page;
-    private int pageSize;
-    private long total;
-
-    public PageResult() {
-    }
-
-    public PageResult(List<T> list, int page, int pageSize, long total) {
-        this.list = list;
-        this.page = page;
-        this.pageSize = pageSize;
-        this.total = total;
-    }
-
-    public List<T> getList() {
-        return list;
-    }
-
-    public void setList(List<T> list) {
-        this.list = list;
-    }
-
-    public int getPage() {
-        return page;
-    }
-
-    public void setPage(int page) {
-        this.page = page;
-    }
-
-    public int getPageSize() {
-        return pageSize;
-    }
-
-    public void setPageSize(int pageSize) {
-        this.pageSize = pageSize;
-    }
-
-    public long getTotal() {
-        return total;
-    }
-
-    public void setTotal(long total) {
-        this.total = total;
-    }
-}
-
-

+ 54 - 0
src/main/java/com/yingpai/gupiao/domain/vo/WxLoginCheckVO.java

@@ -0,0 +1,54 @@
+package com.yingpai.gupiao.domain.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 微信登录检查响应VO
+ * 用于第一步和第二步的响应
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class WxLoginCheckVO {
+    /**
+     * 是否已注册
+     * "true" - 已注册,返回token
+     * "false" - 未注册,需要继续下一步
+     */
+    private String isSign;
+    
+    /**
+     * 登录token(已注册用户)
+     */
+    private String token;
+    
+    /**
+     * 状态码
+     * 103 - 账号被禁用
+     */
+    private Integer code;
+    
+    /**
+     * 微信openid(未注册用户)
+     */
+    private String openid;
+    
+    /**
+     * 微信unionid(未注册用户)
+     */
+    private String unionid;
+    
+    /**
+     * 手机号(未注册用户)
+     */
+    private String phoneNumber;
+    
+    /**
+     * 消息提示
+     */
+    private String message;
+}

+ 22 - 14
src/main/java/com/yingpai/gupiao/service/AuthService.java

@@ -1,33 +1,41 @@
 package com.yingpai.gupiao.service;
 
-import com.yingpai.gupiao.domain.dto.LoginDTO;
+import com.yingpai.gupiao.domain.dto.WxCompleteUserInfoDTO;
+import com.yingpai.gupiao.domain.dto.WxPhoneLoginDTO;
 import com.yingpai.gupiao.domain.vo.LoginVO;
+import com.yingpai.gupiao.domain.vo.WxLoginCheckVO;
 
 /**
  * 认证服务接口
  * 提供用户登录相关功能
+ * 
+ * 完整登录流程:
+ * 1. wxSilentLogin - 检查是否为老用户
+ * 2. wxPhoneCheck - 验证手机号
+ * 3. wxCompleteUserInfo - 完善用户信息
  */
 public interface AuthService {
     
     /**
-     * 微信登录
-     * @param code 微信登录code
-     * @return 登录结果,包含token和用户信息
+     * 第一步:微信静默登录(检查是否为老用户)
+     * @param loginCode 微信登录code
+     * @return 老用户返回token,新用户返回isSign=false
      */
-    LoginVO wxLogin(String code);
+    WxLoginCheckVO wxSilentLogin(String loginCode);
     
     /**
-     * 手机号验证码登录
-     * @param phone 手机号
-     * @param code 验证码
-     * @return 登录结果,包含token和用户信息
+     * 第二步:手机号授权验证
+     * @param dto 包含loginCode和phoneCode
+     * @return 已注册返回token,未注册返回用户信息
      */
-    LoginVO phoneLogin(String phone, String code);
+    WxLoginCheckVO wxPhoneCheck(WxPhoneLoginDTO dto);
     
     /**
-     * 发送短信验证码
-     * @param phone 手机号
-     * @return 是否发送成功
+     * 第三步:完善用户信息(首次登录)
+     * @param dto 包含完整用户信息
+     * @return 返回token
      */
-    boolean sendSmsCode(String phone);
+    LoginVO wxCompleteUserInfo(WxCompleteUserInfoDTO dto);
 }
+
+

+ 174 - 186
src/main/java/com/yingpai/gupiao/service/impl/AuthServiceImpl.java

@@ -1,9 +1,12 @@
 package com.yingpai.gupiao.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.yingpai.gupiao.domain.dto.WxCompleteUserInfoDTO;
 import com.yingpai.gupiao.domain.dto.WxLoginResponse;
+import com.yingpai.gupiao.domain.dto.WxPhoneLoginDTO;
 import com.yingpai.gupiao.domain.po.User;
 import com.yingpai.gupiao.domain.vo.LoginVO;
+import com.yingpai.gupiao.domain.vo.WxLoginCheckVO;
 import com.yingpai.gupiao.mapper.UserMapper;
 import com.yingpai.gupiao.service.AuthService;
 import com.yingpai.gupiao.util.JwtUtil;
@@ -13,11 +16,14 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import java.time.LocalDateTime;
-import java.util.Random;
 
 /**
  * 认证服务实现类
- * 实现用户登录、注册、验证码发送等功能
+ * 
+ * 完整登录流程:
+ * 1. wxSilentLogin - 检查是否为老用户(根据openid)
+ * 2. wxPhoneCheck - 验证手机号(解密获取手机号)
+ * 3. wxCompleteUserInfo - 完善用户信息(创建新用户)
  */
 @Slf4j
 @Service
@@ -28,225 +34,207 @@ public class AuthServiceImpl implements AuthService {
     private final JwtUtil jwtUtil;
     private final WxApiUtil wxApiUtil;
     
-    /**
-     * 临时存储验证码的Map(生产环境应使用Redis)
-     */
-    private static final java.util.Map<String, String> CODE_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
-    
     /**
      * 默认头像URL
      */
     private static final String DEFAULT_AVATAR = "/static/images/head.png";
     
     /**
-     * 微信登录
-     * @param code 微信登录code
-     * @return 登录结果
+     * 第一步:微信静默登录(检查是否为老用户)
+     * @param loginCode 微信登录code
+     * @return 老用户返回token,新用户返回isSign=false
      */
     @Override
-    public LoginVO wxLogin(String code) {
+    public WxLoginCheckVO wxSilentLogin(String loginCode) {
         try {
-            // 调用微信API获取openid和session_key
-            WxLoginResponse wxResponse = wxApiUtil.getOpenidByCode(code);
+            log.info("【第一步】微信静默登录,loginCode: {}", loginCode);
+            
+            // 1. 调用微信API获取openid和unionid
+            WxLoginResponse wxResponse = wxApiUtil.getOpenidByCode(loginCode);
             String openid = wxResponse.getOpenid();
             String unionid = wxResponse.getUnionid();
             
-            log.info("微信登录,openid: {}, unionid: {}", openid, unionid);
+            log.info("获取微信信息成功,openid: {}, unionid: {}", openid, unionid);
             
-            // 根据openid查询用户
+            // 2. 根据openid查询用户
             LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
             wrapper.eq(User::getOpenid, openid);
             User user = userMapper.selectOne(wrapper);
             
-            // 如果用户不存在,创建新用户
+            // 3. 判断用户是否存在
             if (user == null) {
-                user = new User();
-                user.setOpenid(openid);
-                user.setUnionid(unionid);
-                // 使用openid后8位作为用户标识,确保唯一性
-                String userCode = openid.substring(Math.max(0, openid.length() - 8));
-                user.setNickname("微信用户" + userCode);
-                user.setAvatar(DEFAULT_AVATAR);
-                user.setStatus(0);
-                user.setCreateTime(LocalDateTime.now());
-                user.setUpdateTime(LocalDateTime.now());
-                userMapper.insert(user);
-                log.info("创建新用户,userId: {}, openid: {}, nickname: {}", user.getId(), openid, user.getNickname());
-            } else {
-                // 更新unionid(如果有)
-                if (unionid != null && !unionid.equals(user.getUnionid())) {
-                    user.setUnionid(unionid);
-                    user.setUpdateTime(LocalDateTime.now());
-                    userMapper.updateById(user);
-                }
-                log.info("用户已存在,userId: {}, openid: {}", user.getId(), openid);
+                // 新用户,返回 isSign=false
+                log.info("新用户,需要授权手机号");
+                return WxLoginCheckVO.builder()
+                        .isSign("false")
+                        .build();
             }
             
-            // 生成token
-            String token = jwtUtil.generateToken(user.getId(), user.getPhone());
+            // 4. 检查用户状态
+            if (user.getStatus() != null && user.getStatus() == 1) {
+                // 账号被禁用
+                log.warn("账号被禁用,userId: {}", user.getId());
+                return WxLoginCheckVO.builder()
+                        .code(103)
+                        .message("账号已被禁用")
+                        .build();
+            }
             
-            // 构建返回结果
-            return buildLoginVO(user, token);
+            // 5. 老用户,生成token并返回
+            String token = jwtUtil.generateToken(user.getId(), user.getPhone());
+            log.info("老用户登录成功,userId: {}, token已生成", user.getId());
             
+            return WxLoginCheckVO.builder()
+                    .isSign("true")
+                    .token(token)
+                    .build();
+                    
         } catch (Exception e) {
-            log.error("微信登录失败", e);
-            throw new RuntimeException("微信登录失败: " + e.getMessage());
+            log.error("微信静默登录失败", e);
+            throw new RuntimeException("微信静默登录失败: " + e.getMessage());
         }
     }
     
     /**
-     * 手机号验证码登录
-     * @param phone 手机号
-     * @param code 验证码
-     * @return 登录结果
+     * 第二步:手机号授权验证
+     * @param dto 包含loginCode、phoneCode、encryptedData、iv
+     * @return 已注册返回token,未注册返回用户信息
      */
     @Override
-    public LoginVO phoneLogin(String phone, String code) {
-        // 验证手机号格式
-        if (!isValidPhone(phone)) {
-            throw new RuntimeException("手机号格式不正确");
-        }
-        
-        // 验证验证码
-        if (!verifyCode(phone, code)) {
-            throw new RuntimeException("验证码错误或已过期");
-        }
-        
-        // 根据手机号查询用户
-        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(User::getPhone, phone);
-        User user = userMapper.selectOne(wrapper);
-        
-        // 如果用户不存在,创建新用户
-        if (user == null) {
-            user = new User();
-            user.setPhone(phone);
-            user.setNickname(phone); // 使用手机号作为昵称
-            user.setAvatar(DEFAULT_AVATAR); // 使用默认头像
-            user.setStatus(0);
-            user.setCreateTime(LocalDateTime.now());
-            user.setUpdateTime(LocalDateTime.now());
-            userMapper.insert(user);
-            log.info("创建新用户,userId: {}, phone: {}, nickname: {}", user.getId(), phone, user.getNickname());
+    public WxLoginCheckVO wxPhoneCheck(WxPhoneLoginDTO dto) {
+        try {
+            log.info("【第二步】手机号授权验证,loginCode: {}", dto.getLoginCode());
+            
+            // 1. 调用微信API获取openid、unionid和session_key
+            WxLoginResponse wxResponse = wxApiUtil.getOpenidByCode(dto.getLoginCode());
+            String openid = wxResponse.getOpenid();
+            String unionid = wxResponse.getUnionid();
+            String sessionKey = wxResponse.getSessionKey();
+            
+            log.info("获取微信信息成功,openid: {}, unionid: {}, sessionKey已获取", openid, unionid);
+            
+            // 2. 解密手机号(使用encryptedData和iv方式)
+            String phone = null;
+            
+            if (dto.getEncryptedData() != null && dto.getIv() != null && sessionKey != null) {
+                // 使用旧版解密方式(不需要开通手机号快速验证组件)
+                log.info("使用encryptedData解密手机号");
+                phone = wxApiUtil.decryptPhoneNumber(dto.getEncryptedData(), sessionKey, dto.getIv());
+                log.info("解密手机号成功: {}", phone);
+            } else {
+                log.error("缺少解密参数:encryptedData={}, iv={}, sessionKey={}", 
+                    dto.getEncryptedData() != null, dto.getIv() != null, sessionKey != null);
+                throw new RuntimeException("缺少手机号解密参数");
+            }
+            
+            // 3. 根据手机号查询用户
+            LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(User::getPhone, phone);
+            User user = userMapper.selectOne(wrapper);
+            
+            // 4. 判断用户是否存在
+            if (user == null) {
+                // 未注册,返回用户信息供前端完善资料
+                log.info("未注册用户,需要完善信息");
+                return WxLoginCheckVO.builder()
+                        .isSign("false")
+                        .openid(openid)
+                        .unionid(unionid)
+                        .phoneNumber(phone)
+                        .build();
+            }
+            
+            // 5. 已注册用户,更新openid和unionid(如果没有)
+            boolean needUpdate = false;
+            
+            if (user.getOpenid() == null || user.getOpenid().isEmpty()) {
+                user.setOpenid(openid);
+                needUpdate = true;
+            }
+            
+            if (unionid != null && (user.getUnionid() == null || user.getUnionid().isEmpty())) {
+                user.setUnionid(unionid);
+                needUpdate = true;
+            }
+            
+            if (needUpdate) {
+                user.setUpdateTime(LocalDateTime.now());
+                userMapper.updateById(user);
+                log.info("更新用户微信信息,userId: {}", user.getId());
+            }
+            
+            // 6. 生成token并返回
+            String token = jwtUtil.generateToken(user.getId(), user.getPhone());
+            log.info("已注册用户登录成功,userId: {}, token已生成", user.getId());
+            
+            return WxLoginCheckVO.builder()
+                    .isSign("true")
+                    .token(token)
+                    .build();
+                    
+        } catch (Exception e) {
+            log.error("手机号授权验证失败", e);
+            throw new RuntimeException("手机号授权验证失败: " + e.getMessage());
         }
-        
-        // 生成token
-        String token = jwtUtil.generateToken(user.getId(), user.getPhone());
-        
-        // 清除验证码
-        // redisTemplate.delete(SMS_CODE_PREFIX + phone);
-        
-        // 构建返回结果
-        return buildLoginVO(user, token);
     }
     
     /**
-     * 发送短信验证码
-     * @param phone 手机号
-     * @return 是否发送成功
+     * 第三步:完善用户信息(首次登录)
+     * @param dto 包含完整用户信息
+     * @return 返回token
      */
     @Override
-    public boolean sendSmsCode(String phone) {
-        // 验证手机号格式
-        if (!isValidPhone(phone)) {
-            throw new RuntimeException("手机号格式不正确");
-        }
-        
-        // 生成6位随机验证码
-        String code = generateCode();
-        
-        // TODO: 调用短信服务发送验证码
-        // 实际开发中需要对接阿里云、腾讯云等短信服务
-        log.info("发送验证码到手机号: {}, 验证码: {}", phone, code);
-        
-        // 将验证码存储到Redis(如果有Redis)
-        // redisTemplate.opsForValue().set(
-        //     SMS_CODE_PREFIX + phone, 
-        //     code, 
-        //     CODE_EXPIRE_MINUTES, 
-        //     TimeUnit.MINUTES
-        // );
-        
-        // 临时方案:存储到内存(仅用于开发测试,生产环境必须使用Redis)
-        CODE_CACHE.put(phone, code);
-        log.info("验证码已存入缓存,phone: {}, code: {}", phone, code);
-        
-        return true;
-    }
-    
-    /**
-     * 验证验证码
-     * @param phone 手机号
-     * @param code 验证码
-     * @return 是否验证通过
-     */
-    private boolean verifyCode(String phone, String code) {
-        // 从Redis获取验证码
-        // String cachedCode = redisTemplate.opsForValue().get(SMS_CODE_PREFIX + phone);
-        // return code.equals(cachedCode);
-        
-        // 临时方案:从内存获取验证码
-        String cachedCode = CODE_CACHE.get(phone);
-        log.info("验证验证码,phone: {}, 输入code: {}, 缓存code: {}", phone, code, cachedCode);
-        
-        if (cachedCode == null) {
-            log.warn("验证码不存在或已过期,phone: {}", phone);
-            return false;
-        }
-        
-        boolean isValid = code.equals(cachedCode);
-        if (isValid) {
-            // 验证成功后删除验证码
-            CODE_CACHE.remove(phone);
-            log.info("验证码验证成功,phone: {}", phone);
-        } else {
-            log.warn("验证码错误,phone: {}, 输入: {}, 期望: {}", phone, code, cachedCode);
-        }
-        
-        return isValid;
-    }
-    
-    /**
-     * 生成6位随机验证码
-     * @return 验证码
-     */
-    private String generateCode() {
-        Random random = new Random();
-        return String.format("%06d", random.nextInt(1000000));
-    }
-    
-    /**
-     * 验证手机号格式
-     * @param phone 手机号
-     * @return 是否有效
-     */
-    private boolean isValidPhone(String phone) {
-        return phone != null && phone.matches("^1[3-9]\\d{9}$");
-    }
-    
-    /**
-     * 构建登录响应VO
-     * @param user 用户实体
-     * @param token JWT token
-     * @return LoginVO
-     */
-    private LoginVO buildLoginVO(User user, String token) {
-        // 手机号脱敏
-        String maskedPhone = null;
-        if (user.getPhone() != null) {
-            maskedPhone = user.getPhone().replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
+    public LoginVO wxCompleteUserInfo(WxCompleteUserInfoDTO dto) {
+        try {
+            log.info("【第三步】完善用户信息,openid: {}, phone: {}, nickname: {}", 
+                    dto.getOpenid(), dto.getPhoneNumber(), dto.getNickname());
+            
+            // 1. 检查手机号是否已被注册
+            LambdaQueryWrapper<User> phoneWrapper = new LambdaQueryWrapper<>();
+            phoneWrapper.eq(User::getPhone, dto.getPhoneNumber());
+            User existUser = userMapper.selectOne(phoneWrapper);
+            
+            if (existUser != null) {
+                log.warn("手机号已被注册,phone: {}, userId: {}", dto.getPhoneNumber(), existUser.getId());
+                throw new RuntimeException("该手机号已被注册");
+            }
+            
+            // 2. 检查openid是否已存在
+            LambdaQueryWrapper<User> openidWrapper = new LambdaQueryWrapper<>();
+            openidWrapper.eq(User::getOpenid, dto.getOpenid());
+            existUser = userMapper.selectOne(openidWrapper);
+            
+            if (existUser != null) {
+                log.warn("openid已存在,openid: {}, userId: {}", dto.getOpenid(), existUser.getId());
+                throw new RuntimeException("该微信账号已注册");
+            }
+            
+            // 3. 创建新用户
+            User newUser = new User();
+            newUser.setOpenid(dto.getOpenid());
+            newUser.setUnionid(dto.getUnionid());
+            newUser.setPhone(dto.getPhoneNumber());
+            newUser.setNickname(dto.getNickname());
+            newUser.setAvatar(dto.getAvatarUrl() != null ? dto.getAvatarUrl() : DEFAULT_AVATAR);
+            newUser.setStatus(0);
+            newUser.setCreateTime(LocalDateTime.now());
+            newUser.setUpdateTime(LocalDateTime.now());
+            
+            userMapper.insert(newUser);
+            log.info("创建新用户成功,userId: {}, openid: {}, phone: {}, nickname: {}", 
+                    newUser.getId(), dto.getOpenid(), dto.getPhoneNumber(), dto.getNickname());
+            
+            // 4. 生成token
+            String token = jwtUtil.generateToken(newUser.getId(), newUser.getPhone());
+            
+            // 5. 返回token
+            return LoginVO.builder()
+                    .token(token)
+                    .build();
+            
+        } catch (Exception e) {
+            log.error("完善用户信息失败", e);
+            throw new RuntimeException("注册失败: " + e.getMessage());
         }
-        
-        LoginVO.UserInfoVO userInfo = LoginVO.UserInfoVO.builder()
-                .id(user.getId())
-                .nickname(user.getNickname())
-                .avatar(user.getAvatar())
-                .phone(maskedPhone)
-                .build();
-        
-        return LoginVO.builder()
-                .token(token)
-                .userInfo(userInfo)
-                .build();
     }
 }

+ 135 - 0
src/main/java/com/yingpai/gupiao/util/WxApiUtil.java

@@ -3,11 +3,18 @@ package com.yingpai.gupiao.util;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.yingpai.gupiao.config.WxConfig;
 import com.yingpai.gupiao.domain.dto.WxLoginResponse;
+import com.yingpai.gupiao.domain.dto.WxPhoneResponse;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Component;
 import org.springframework.web.client.RestTemplate;
 
+import java.util.HashMap;
+import java.util.Map;
+
 /**
  * 微信API工具类
  * 封装微信小程序相关API调用
@@ -26,6 +33,21 @@ public class WxApiUtil {
      */
     private static final String WX_LOGIN_URL = "https://api.weixin.qq.com/sns/jscode2session";
     
+    /**
+     * 获取access_token接口地址
+     */
+    private static final String WX_ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
+    
+    /**
+     * 缓存的access_token
+     */
+    private String cachedAccessToken;
+    
+    /**
+     * access_token过期时间
+     */
+    private long accessTokenExpireTime;
+    
     /**
      * 调用微信API获取openid和session_key
      * @param code 小程序登录时获取的code
@@ -73,4 +95,117 @@ public class WxApiUtil {
             throw new Exception("调用微信API失败: " + e.getMessage());
         }
     }
+    
+    /**
+     * 获取access_token
+     * @return access_token
+     * @throws Exception 调用失败时抛出异常
+     */
+    public String getAccessToken() throws Exception {
+        // 如果缓存的token还未过期,直接返回
+        if (cachedAccessToken != null && System.currentTimeMillis() < accessTokenExpireTime) {
+            log.info("使用缓存的access_token");
+            return cachedAccessToken;
+        }
+        
+        // 构建请求URL
+        String url = String.format(
+            "%s?grant_type=client_credential&appid=%s&secret=%s",
+            WX_ACCESS_TOKEN_URL,
+            wxConfig.getAppid(),
+            wxConfig.getSecret()
+        );
+        
+        log.info("获取access_token,appid: {}", wxConfig.getAppid());
+        
+        try {
+            // 调用微信API
+            String response = restTemplate.getForObject(url, String.class);
+            log.info("获取access_token响应: {}", response);
+            
+            // 解析响应
+            @SuppressWarnings("unchecked")
+            Map<String, Object> result = objectMapper.readValue(response, Map.class);
+            
+            // 检查是否有错误
+            if (result.containsKey("errcode")) {
+                Integer errcode = (Integer) result.get("errcode");
+                if (errcode != 0) {
+                    String errmsg = (String) result.get("errmsg");
+                    log.error("获取access_token失败,errcode: {}, errmsg: {}", errcode, errmsg);
+                    throw new RuntimeException("获取access_token失败: " + errmsg);
+                }
+            }
+            
+            // 获取access_token
+            String accessToken = (String) result.get("access_token");
+            Integer expiresIn = (Integer) result.get("expires_in");
+            
+            if (accessToken == null || accessToken.isEmpty()) {
+                log.error("获取access_token失败,未返回access_token");
+                throw new RuntimeException("获取access_token失败");
+            }
+            
+            // 缓存access_token(提前5分钟过期)
+            cachedAccessToken = accessToken;
+            accessTokenExpireTime = System.currentTimeMillis() + (expiresIn - 300) * 1000L;
+            
+            log.info("获取access_token成功,有效期: {}秒,token: {}", expiresIn, accessToken);
+            return accessToken;
+            
+        } catch (Exception e) {
+            log.error("获取access_token异常", e);
+            throw new Exception("获取access_token失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 解密手机号(旧版方式,使用encryptedData和iv)
+     * 不需要开通"手机号快速验证组件"权限
+     * 
+     * @param encryptedData 加密数据
+     * @param sessionKey 会话密钥
+     * @param iv 加密算法的初始向量
+     * @return 解密后的手机号
+     * @throws Exception 解密失败时抛出异常
+     */
+    public String decryptPhoneNumber(String encryptedData, String sessionKey, String iv) throws Exception {
+        try {
+            log.info("开始解密手机号");
+            
+            // Base64解码
+            byte[] encryptedDataBytes = java.util.Base64.getDecoder().decode(encryptedData);
+            byte[] sessionKeyBytes = java.util.Base64.getDecoder().decode(sessionKey);
+            byte[] ivBytes = java.util.Base64.getDecoder().decode(iv);
+            
+            // AES解密
+            javax.crypto.spec.SecretKeySpec keySpec = new javax.crypto.spec.SecretKeySpec(sessionKeyBytes, "AES");
+            javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding");
+            javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(ivBytes);
+            cipher.init(javax.crypto.Cipher.DECRYPT_MODE, keySpec, ivSpec);
+            
+            byte[] decryptedBytes = cipher.doFinal(encryptedDataBytes);
+            String decryptedData = new String(decryptedBytes, "UTF-8");
+            
+            log.info("解密成功");
+            
+            // 解析JSON获取手机号
+            @SuppressWarnings("unchecked")
+            Map<String, Object> dataMap = objectMapper.readValue(decryptedData, Map.class);
+            
+            String phoneNumber = (String) dataMap.get("purePhoneNumber");
+            
+            if (phoneNumber == null || phoneNumber.isEmpty()) {
+                log.error("解密数据中未找到手机号");
+                throw new RuntimeException("解密数据中未找到手机号");
+            }
+            
+            log.info("成功提取手机号: {}", phoneNumber);
+            return phoneNumber;
+            
+        } catch (Exception e) {
+            log.error("解密手机号失败", e);
+            throw new Exception("解密手机号失败: " + e.getMessage());
+        }
+    }
 }

+ 10 - 2
src/main/resources/application.properties

@@ -1,6 +1,6 @@
 #??
-wx.appid=wx6e0cde280c822198
-wx.secret=55e70d914cc4c0b5dddaef9a956e000b
+wx.appid=wxcf9eec0da6a6b696
+wx.secret=2808024de706bfb74d0fe1a111dfa00d
 
 spring.application.name=gupiao
 
@@ -14,6 +14,14 @@ spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
 # MyBatis-Plus
 mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
 
+#????????
+file.upload.path=D:/program/gupiao/uploads
+
+file.access.url=http://localhost:8080
+# Spring Boot
+spring.servlet.multipart.max-file-size=10MB
+spring.servlet.multipart.max-request-size=10MB
+
 logging.level.root=info
 logging.level.com.yingpai.gupiao=debug
 logging.level.com.baomidou.mybatisplus=debug

BIN
uploads/2025/12/23/d08bb6b8-eb6f-41c8-b061-547722c24f3e.jpeg