Просмотр исходного кода

feat(auth): 添加小程序微信登录功能

- 新增ChangeUserPwdBo类添加oldPassword字段用于密码验证
- 实现小程序用户微信登录、手机号绑定、文件上传等功能
- 添加SysUserWechat相关实体类、业务对象和数据传输对象
- 集成微信API实现openid获取、手机号解密等核心功能
- 支持头像图片转存OSS并添加租户隔离功能
- 实现密码修改时的新旧密码一致性校验
- 添加HTTP客户端工具类用于微信API调用
- 配置MyBatis和OSS依赖支持数据库操作和文件上传
hurx 1 месяц назад
Родитель
Сommit
367843d8f9

+ 8 - 0
ruoyi-auth/pom.xml

@@ -100,6 +100,14 @@
             <groupId>org.dromara</groupId>
             <artifactId>ruoyi-api-customer</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.dromara</groupId>
+            <artifactId>ruoyi-common-mybatis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.dromara</groupId>
+            <artifactId>ruoyi-common-oss</artifactId>
+        </dependency>
 
         <!-- 自定义负载均衡(多团队开发使用) -->
 <!--        <dependency>-->

+ 340 - 0
ruoyi-auth/src/main/java/org/dromara/auth/controller/MiniTokenController.java

@@ -0,0 +1,340 @@
+package org.dromara.auth.controller;
+
+import cn.dev33.satoken.annotation.SaIgnore;
+import cn.dev33.satoken.stp.StpUtil;
+import cn.dev33.satoken.stp.parameter.SaLoginParameter;
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson2.JSONObject;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.dubbo.config.annotation.DubboReference;
+import org.apache.seata.spring.annotation.GlobalTransactional;
+import org.dromara.auth.domain.SysUserWechat;
+import org.dromara.auth.domain.bo.SysUserWechatBo;
+import org.dromara.auth.domain.vo.SysUserWechatVo;
+import org.dromara.auth.service.MiniAuthService;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.utils.ServletUtils;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.common.tenant.helper.TenantHelper;
+import org.dromara.resource.api.RemoteFileService;
+import org.dromara.resource.api.domain.RemoteFile;
+import org.dromara.system.api.model.LoginUser;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * mini端 控制器
+ *
+ * @description:
+ * @create: 2026-04-21
+ **/
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/wx")
+public class MiniTokenController {
+
+    private final MiniAuthService miniAuthService;
+    @DubboReference
+    private RemoteFileService remoteFileService;
+
+    @Value("${tr.wechat.secret}")
+    private String appSecret;
+
+    @Value("${tr.wechat.appid}")
+    private String appId;
+
+    /*
+     * 小程序登录
+     * */
+    @SaIgnore
+    @PostMapping("/login")
+    public R<SysUserWechatVo> login(@RequestBody SysUserWechatBo wechatBo) {
+        log.info("微信用户登录:{}", wechatBo.getCode());
+
+        // 从请求头中获取 tenantId
+        String tenantId = ServletUtils.getHeader(Objects.requireNonNull(ServletUtils.getRequest()), "tenantId");
+        log.info("从请求头获取到 tenantId: {}", tenantId);
+
+        // 在动态租户上下文中执行登录逻辑
+        return TenantHelper.dynamic(tenantId, () -> {
+            SysUserWechat wechat = miniAuthService.wxLogin(wechatBo);
+            LoginUser loginUser = new LoginUser();
+            loginUser.setUserId(wechat.getUserId());
+            loginUser.setNickname(wechat.getNickname());
+            loginUser.setUserType("app_user");
+            loginUser.setOpenid(wechat.getOpenId());
+            loginUser.setTenantId(tenantId); // 设置租户ID到登录用户
+
+            // 配置登录参数
+            SaLoginParameter model = new SaLoginParameter();
+            model.setDeviceType("xcx"); // 小程序设备类型
+            model.setTimeout(30 * 24 * 60 * 60); // 30天过期时间
+            model.setActiveTimeout(7 * 24 * 60 * 60); // 7天活跃超时
+            model.setExtra(LoginHelper.CLIENT_KEY, "miniprogram_client_id_2025"); // 使用系统认可的APP端客户端ID
+
+            //生成token
+            LoginHelper.login(loginUser, model);
+            // 获取token信息
+            String accessToken = StpUtil.getTokenValue();
+            Long expireIn = StpUtil.getTokenTimeout();
+            SysUserWechatVo wechatVo = BeanUtil.copyProperties(wechat, SysUserWechatVo.class);
+            wechatVo.setOpenId(wechat.getOpenId());
+            wechatVo.setAccessToken(accessToken);
+            wechatVo.setExpireIn(expireIn);
+            return R.ok(wechatVo);
+        });
+    }
+
+
+    @SaIgnore
+    @PostMapping("/bindPhone")
+    @GlobalTransactional
+    public R<SysUserWechatVo> getUserPhone(@RequestBody Map<String, Object> phoneData) {
+        try {
+            String code = (String) phoneData.get("code");
+            Long userId = Long.valueOf(phoneData.get("userId").toString());
+
+            log.info("获取手机号请求 - userId: {}, code: {}", userId, code);
+
+            // 从请求头中获取 tenantId
+            String tenantId = ServletUtils.getHeader(Objects.requireNonNull(ServletUtils.getRequest()), "tenantId");
+            log.info("从请求头获取到 tenantId: {}", tenantId);
+
+            // 在动态租户上下文中执行绑定手机号逻辑
+            return TenantHelper.dynamic(tenantId, () -> {
+                // 1. 通过手机号专用code直接获取手机号(新方法)
+                String phoneNumber = getPhoneNumberByCode(code);
+                if (phoneNumber == null) {
+                    return R.fail("获取手机号失败");
+                }
+
+                // 2. 验证手机号是否已被其他用户使用
+                if (isPhoneNumberExists(phoneNumber, userId)) {
+                    return R.fail("该手机号已被其他用户绑定");
+                }
+
+                log.info("解密得到手机号: {}", phoneNumber.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"));
+
+                // 3. 更新用户手机号
+                SysUserWechatVo user = miniAuthService.queryById(userId);
+                if (user == null) {
+                    return R.fail("用户不存在");
+                }
+
+                user.setPhoneNumber(phoneNumber);
+                // 创建BO对象进行更新
+                SysUserWechatBo updateBo = new SysUserWechatBo();
+                updateBo.setUserId(userId);
+                updateBo.setPhoneNumber(phoneNumber);
+                miniAuthService.updateByBo(updateBo);
+
+                // 4. 重新生成token并返回用户信息
+                LoginUser loginUser = new LoginUser();
+                loginUser.setUserId(user.getUserId());
+                loginUser.setNickname(user.getNickname());
+                loginUser.setUserType("app_user");
+                loginUser.setOpenid(user.getOpenId());
+                loginUser.setTenantId(tenantId); // 设置租户ID到登录用户
+
+                // 配置登录参数
+                SaLoginParameter model = new SaLoginParameter();
+                model.setDeviceType("xcx");
+                model.setTimeout(30 * 24 * 60 * 60);
+                model.setActiveTimeout(7 * 24 * 60 * 60);
+                model.setExtra(LoginHelper.CLIENT_KEY, "miniprogram_client_id_2025");
+
+                // 生成token
+                LoginHelper.login(loginUser, model);
+
+                // 获取token信息
+                String accessToken = StpUtil.getTokenValue();
+                Long expireIn = StpUtil.getTokenTimeout();
+                SysUserWechatVo userVo = new SysUserWechatVo();
+                userVo.setOpenId(user.getOpenId());
+                userVo.setUserId(user.getUserId());
+                userVo.setStatus(user.getStatus());
+                userVo.setAccessToken(accessToken);
+                userVo.setExpireIn(expireIn);
+                userVo.setNickname(user.getNickname());
+                userVo.setAvatarUrl(user.getAvatarUrl());
+                userVo.setPhoneNumber(phoneNumber); // 返回手机号
+
+                return R.ok(userVo);
+            });
+
+        } catch (Exception e) {
+            log.error("获取手机号失败", e);
+            return R.fail("获取手机号失败: " + e.getMessage());
+        }
+    }
+
+
+    /*
+     * 获取手机号
+     * */
+    @PostMapping("/getPhoneNumber")
+    public Object getPhoneNumber(@RequestBody Map<String, String> data) {
+        return R.ok(getPhoneNumberByCode(data.get("code")));
+    }
+
+
+    private String getPhoneNumberByCode(String code) {
+        try {
+            // 1. 获取 access_token
+            String accessToken = getAccessToken();
+            if (accessToken == null) {
+                log.error("access_token 获取失败");
+                return null;
+            }
+
+            // 2. 构建请求 URL
+            String url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken;
+
+            // 3. 设置请求头为 JSON 格式(重点修复)
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+
+            // 4. 构建请求体(使用 JSONObject 或字符串)
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("code", code);
+
+            // 5. 组装 HTTP 请求
+            HttpEntity<String> requestEntity = new HttpEntity<>(requestBody.toString(), headers);
+
+            // 6. 发送 POST 请求
+            RestTemplate restTemplate = new RestTemplate();
+            ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
+
+            log.info("微信API响应: {}", response.getBody());
+
+            // 7. 解析响应
+            ObjectMapper mapper = new ObjectMapper();
+            JsonNode jsonNode = mapper.readTree(response.getBody());
+
+            // 检查错误码
+            if (jsonNode.has("errcode") && jsonNode.get("errcode").asInt() != 0) {
+                int errcode = jsonNode.get("errcode").asInt();
+                String errmsg = jsonNode.get("errmsg").asText();
+                log.error("微信API返回错误 - errcode: {}, errmsg: {}", errcode, errmsg);
+                return null;
+            }
+
+            // 提取手机号
+            if (jsonNode.has("phone_info")) {
+                JsonNode phoneInfo = jsonNode.get("phone_info");
+                if (phoneInfo.has("phoneNumber")) {
+                    String phoneNumber = phoneInfo.get("phoneNumber").asText();
+                    log.info("成功获取手机号");
+                    return phoneNumber;
+                }
+            }
+
+            log.error("响应中没有找到手机号");
+            return null;
+
+        } catch (Exception e) {
+            log.error("获取手机号异常", e);
+            return null;
+        }
+    }
+
+    /**
+     * 获取小程序 access_token
+     */
+    private String getAccessToken() {
+        try {
+            String url = String.format(
+                "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
+                appId, appSecret
+            );
+
+            RestTemplate restTemplate = new RestTemplate();
+            ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
+
+            ObjectMapper mapper = new ObjectMapper();
+            JsonNode jsonNode = mapper.readTree(response.getBody());
+
+            if (jsonNode.has("access_token")) {
+                return jsonNode.get("access_token").asText();
+            }
+
+            log.error("获取access_token失败: {}", response.getBody());
+            return null;
+        } catch (Exception e) {
+            log.error("获取access_token异常", e);
+            return null;
+        }
+    }
+
+    /**
+     * 检查手机号是否已被其他用户使用
+     */
+    private boolean isPhoneNumberExists(String phoneNumber, Long excludeUserId) {
+        try {
+            // 这里需要调用service层方法查询数据库
+            // 由于没有现成的方法,暂时返回false,建议后续完善
+            // WxVsUser existingUser = vsUserService.selectByPhoneNumber(phoneNumber);
+            // return existingUser != null && !existingUser.getUserId().equals(excludeUserId);
+
+            log.info("检查手机号是否存在: {} (排除用户: {})",
+                phoneNumber.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"), excludeUserId);
+            SysUserWechat existingUser = miniAuthService.queryByPhoneNumber(phoneNumber);
+            return existingUser != null && !existingUser.getUserId().equals(excludeUserId);
+        } catch (Exception e) {
+            log.error("检查手机号重复时发生异常", e);
+            return false;
+        }
+    }
+
+    /**
+     * 小程序文件上传(无需权限)
+     *
+     * @param file 上传的文件
+     * @return 上传结果,包含文件URL和ossId
+     */
+    @SaIgnore
+    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public R<RemoteFile> upload(@RequestPart("file") MultipartFile file) {
+        try {
+            if (ObjectUtil.isNull(file)) {
+                return R.fail("上传文件不能为空");
+            }
+            log.info("小程序上传文件: {}, 大小: {} bytes", file.getOriginalFilename(), file.getSize());
+
+            // 通过Dubbo远程调用resource服务上传文件
+            RemoteFile remoteFile = remoteFileService.upload(
+                file.getName(),
+                file.getOriginalFilename(),
+                file.getContentType(),
+                file.getBytes()
+            );
+
+            log.info("文件上传成功: ossId={}, url={}", remoteFile.getOssId(), remoteFile.getUrl());
+            return R.ok(remoteFile);
+        } catch (Exception e) {
+            log.error("小程序文件上传失败", e);
+            return R.fail("文件上传失败: " + e.getMessage());
+        }
+    }
+
+    @GetMapping("/userInfo/{userId}")
+    public R<SysUserWechatVo> getInfo(@NotNull(message = "主键不能为空")
+                                      @PathVariable("userId") Long id) {
+        return R.ok(miniAuthService.queryById(id));
+    }
+}

+ 97 - 0
ruoyi-auth/src/main/java/org/dromara/auth/domain/SysUserWechat.java

@@ -0,0 +1,97 @@
+package org.dromara.auth.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.io.Serial;
+
+/**
+ * 小程序用户关联对象 sys_user_wechat
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_user_wechat")
+public class SysUserWechat extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    private Long userId;
+
+    /**
+     * 微信小程序唯一标识
+     */
+    private String openId;
+
+    /**
+     * 微信开放平台唯一标识
+     */
+    private String unionId;
+
+    /**
+     * 微信昵称
+     */
+    private String nickname;
+
+    /**
+     * 头像URL
+     */
+    private String avatarUrl;
+
+    /**
+     * 性别:  0. 男; 1. 女;2. 未知;
+     */
+    private Long gender;
+
+    /**
+     * 微信会话密钥(仅后端临时存储,需加密!)
+     */
+    private String sessionKey;
+
+    /**
+     * 逻辑删除(0=未删除,1=已删除)
+     */
+    @TableLogic
+    private String delFlag;
+
+    /**
+     * 微信API临时访问令牌
+     */
+    private String accessToken;
+
+    /**
+     * access_token的有效期(秒)
+     */
+    private Long expireIn;
+
+    /**
+     * 用于刷新access_token的令牌
+     */
+    private String refreshToken;
+
+    /**
+     * 状态(0正常 1停用)
+     */
+    private String status;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /*
+     *电话号码
+     */
+    private String phoneNumber;
+
+}

+ 99 - 0
ruoyi-auth/src/main/java/org/dromara/auth/domain/bo/SysUserWechatBo.java

@@ -0,0 +1,99 @@
+package org.dromara.auth.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.auth.domain.SysUserWechat;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+/**
+ * 小程序用户关联业务对象 sys_user_wechat
+ *
+ * @author LionLi
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = SysUserWechat.class, reverseConvertGenerate = false)
+public class SysUserWechatBo extends BaseEntity {
+
+    /**
+     * 主键ID
+     */
+    private Long id;
+
+    private Long userId;
+
+    /**
+     * 微信小程序唯一标识
+     */
+    private String openId;
+
+    /**
+     * 微信开放平台唯一标识
+     */
+    @NotBlank(message = "微信开放平台唯一标识不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String unionId;
+
+    /**
+     * 微信昵称
+     */
+    @NotBlank(message = "微信昵称不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String nickname;
+
+    /**
+     * 头像URL
+     */
+    @NotBlank(message = "头像URL不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String avatarUrl;
+
+    /**
+     * 性别:  0. 男; 1. 女;2. 未知;
+     */
+    @NotNull(message = "性别(0=未知,1=男,2=女)不能为空", groups = {AddGroup.class, EditGroup.class})
+    private Long gender;
+
+    /**
+     * 微信会话密钥(仅后端临时存储,需加密!)
+     */
+    @NotBlank(message = "微信会话密钥(仅后端临时存储,需加密!)不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String sessionKey;
+
+    /**
+     * 微信API临时访问令牌
+     */
+    @NotBlank(message = "微信API临时访问令牌不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String accessToken;
+
+    /**
+     * access_token的有效期(秒)
+     */
+    @NotNull(message = "access_token的有效期(秒)不能为空", groups = {AddGroup.class, EditGroup.class})
+    private Long expireIn;
+
+    /**
+     * 用于刷新access_token的令牌
+     */
+    @NotBlank(message = "用于刷新access_token的令牌不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String refreshToken;
+
+    /**
+     * 状态(0正常 1停用)
+     */
+    @NotBlank(message = "状态(0正常 1停用)不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String status;
+
+    /**
+     * 备注
+     */
+    @NotBlank(message = "备注不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String remark;
+
+    private String code;
+
+    private String phoneNumber;
+
+}

+ 113 - 0
ruoyi-auth/src/main/java/org/dromara/auth/domain/vo/SysUserWechatVo.java

@@ -0,0 +1,113 @@
+package org.dromara.auth.domain.vo;
+
+import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
+import cn.idev.excel.annotation.ExcelProperty;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.auth.domain.SysUserWechat;
+import org.dromara.common.excel.annotation.ExcelDictFormat;
+import org.dromara.common.excel.convert.ExcelDictConvert;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+
+/**
+ * 小程序用户关联视图对象 sys_user_wechat
+ *
+ * @author LionLi
+ */
+@Data
+@ExcelIgnoreUnannotated
+@AutoMapper(target = SysUserWechat.class)
+public class SysUserWechatVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @ExcelProperty(value = "主键ID")
+    private Long id;
+
+    private Long userId;
+
+    /**
+     * 微信小程序唯一标识
+     */
+    @ExcelProperty(value = "微信小程序唯一标识")
+    private String openId;
+
+    /**
+     * 微信开放平台唯一标识
+     */
+    @ExcelProperty(value = "微信开放平台唯一标识")
+    private String unionId;
+
+    /**
+     * 微信昵称
+     */
+    @ExcelProperty(value = "微信昵称")
+    private String nickname;
+
+    /**
+     * 头像URL
+     */
+    @ExcelProperty(value = "头像URL")
+    private String avatarUrl;
+
+    /**
+     * 性别:  0. 男; 1. 女;2. 未知;
+     */
+    @ExcelProperty(value = "性别", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(readConverterExp = "0==未知,1=男,2=女")
+    private Long gender;
+
+    /**
+     * 微信会话密钥(仅后端临时存储,需加密!)
+     */
+    @ExcelProperty(value = "微信会话密钥", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(readConverterExp = "仅=后端临时存储,需加密!")
+    private String sessionKey;
+
+    /**
+     * 微信API临时访问令牌
+     */
+    @ExcelProperty(value = "微信API临时访问令牌")
+    private String accessToken;
+
+    /**
+     * access_token的有效期(秒)
+     */
+    @ExcelProperty(value = "access_token的有效期", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(readConverterExp = "秒=")
+    private Long expireIn;
+
+    /**
+     * 用于刷新access_token的令牌
+     */
+    @ExcelProperty(value = "用于刷新access_token的令牌")
+    private String refreshToken;
+
+    @ExcelProperty(value = "创建时间")
+    private Date createTime;
+
+    /**
+     * 状态(0正常 1停用)
+     */
+    @ExcelProperty(value = "状态", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(readConverterExp = "0=正常,1=停用")
+    private String status;
+
+    /**
+     * 备注
+     */
+    @ExcelProperty(value = "备注")
+    private String remark;
+
+    @ExcelProperty(value = "电话号码")
+    private String phoneNumber;
+
+}

+ 15 - 0
ruoyi-auth/src/main/java/org/dromara/auth/mapper/SysUserWechatMapper.java

@@ -0,0 +1,15 @@
+package org.dromara.auth.mapper;
+
+import org.dromara.auth.domain.SysUserWechat;
+import org.dromara.auth.domain.vo.SysUserWechatVo;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+
+/**
+ * 小程序用户关联Mapper接口
+ *
+ * @author LionLi
+ * @date 2026-04-22
+ */
+public interface SysUserWechatMapper extends BaseMapperPlus<SysUserWechat, SysUserWechatVo> {
+
+}

+ 17 - 0
ruoyi-auth/src/main/java/org/dromara/auth/properties/WeChatProperties.java

@@ -0,0 +1,17 @@
+package org.dromara.auth.properties;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * @create: 2026-04-22 09:38
+ * @version: 1.0
+ **/
+@Component
+@ConfigurationProperties(prefix = "tr.wechat")
+@Data
+public class WeChatProperties {
+    private String appid; //小程序的appid
+    private String secret; //小程序的秘钥
+}

+ 17 - 0
ruoyi-auth/src/main/java/org/dromara/auth/service/MiniAuthService.java

@@ -0,0 +1,17 @@
+package org.dromara.auth.service;
+
+
+import org.dromara.auth.domain.SysUserWechat;
+import org.dromara.auth.domain.bo.SysUserWechatBo;
+import org.dromara.auth.domain.vo.SysUserWechatVo;
+
+public interface MiniAuthService {
+
+    SysUserWechat wxLogin(SysUserWechatBo wechatBo);
+
+    SysUserWechatVo queryById(Long id);
+
+    Boolean updateByBo(SysUserWechatBo bo);
+
+    SysUserWechat queryByPhoneNumber(String phoneNumber);
+}

+ 299 - 0
ruoyi-auth/src/main/java/org/dromara/auth/service/impl/MiniAuthServiceImpl.java

@@ -0,0 +1,299 @@
+package org.dromara.auth.service.impl;
+
+import cn.hutool.crypto.digest.BCrypt;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.dubbo.config.annotation.DubboReference;
+import org.apache.seata.spring.annotation.GlobalTransactional;
+import org.dromara.auth.domain.SysUserWechat;
+import org.dromara.auth.domain.bo.SysUserWechatBo;
+import org.dromara.auth.domain.vo.SysUserWechatVo;
+import org.dromara.auth.mapper.SysUserWechatMapper;
+import org.dromara.auth.properties.WeChatProperties;
+import org.dromara.auth.service.MiniAuthService;
+import org.dromara.auth.util.HttpClientUtil;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.oss.core.OssClient;
+import org.dromara.common.oss.entity.UploadResult;
+import org.dromara.common.oss.factory.OssFactory;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.common.tenant.helper.TenantHelper;
+import org.dromara.system.api.RemoteUserService;
+import org.dromara.system.api.domain.bo.RemoteUserBo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @program: photo-studio
+ * @description:
+ * @author: king
+ * @create: 2025-12-05 10:43
+ * @version: 1.0
+ **/
+@Slf4j
+@Service
+public class MiniAuthServiceImpl implements MiniAuthService {
+    public static final String WX_LOGIN = "https://api.weixin.qq.com/sns/jscode2session";
+
+    @DubboReference
+    private RemoteUserService remoteUserService;
+
+    @Autowired
+    private WeChatProperties weChatProperties;
+    @Autowired
+    private SysUserWechatMapper wechatMapper;
+
+    @Override
+    @GlobalTransactional
+    @Transactional(rollbackFor = Exception.class)
+    public SysUserWechat wxLogin(SysUserWechatBo wechatBo) {
+
+        String openid = getOpenid(wechatBo.getCode());
+
+        // 判断openid是否为空,如果为空表示登录失败,抛出异常
+        if (openid == null) {
+            throw new RuntimeException("登录code为空,登录失败");
+        }
+
+        LambdaQueryWrapper<SysUserWechat> lqw =
+            new LambdaQueryWrapper<SysUserWechat>().eq(SysUserWechat::getOpenId, openid);
+        // 判断当前用户是否为新用户
+        SysUserWechat user = wechatMapper.selectOne(lqw);
+        // 如果是新用户,自动完成注册(写入昵称与头像)
+        if (user == null) {
+            user = new SysUserWechat();
+            user.setOpenId(openid);
+            user.setStatus("0");
+
+            // 从当前租户上下文中获取 tenantId 并设置
+            String tenantId = TenantHelper.getTenantId();
+            if (StringUtils.isNotBlank(tenantId)) {
+                user.setTenantId(tenantId);
+                log.info("注册新用户,设置 tenantId: {}", tenantId);
+            } else {
+                log.warn("注册新用户时未获取到 tenantId");
+            }
+
+            if (StringUtils.isNotBlank(wechatBo.getNickname())) {
+                user.setNickname(wechatBo.getNickname());
+            }
+            if (StringUtils.isNotBlank(wechatBo.getAvatarUrl())) {
+                String ossUrl = transferImageToOss(wechatBo.getAvatarUrl());
+                if (StringUtils.isNotBlank(ossUrl)) {
+                    if (ossUrl.length() > 200) {
+                        ossUrl = ossUrl.substring(0, 200);
+                    }
+                    user.setAvatarUrl(ossUrl);
+                }
+            }
+            // 创建系统用户
+            RemoteUserBo remoteUserBo = new RemoteUserBo();
+            remoteUserBo.setDeptId(null);
+            remoteUserBo.setUserName(wechatBo.getNickname());
+            remoteUserBo.setNickName(wechatBo.getNickname());
+            remoteUserBo.setPhonenumber(wechatBo.getPhoneNumber());
+            // remoteUserBo.setRoleId(1996816313015631873L);
+            remoteUserBo.setPassword(BCrypt.hashpw("123456"));
+            remoteUserBo.setUserSonType("4"); // 小程序用户
+            remoteUserBo.setTenantId(LoginHelper.getTenantId());
+            remoteUserBo.setStatus("0"); // 正常状态
+            Long userId = remoteUserService.addUser(remoteUserBo);
+            user.setUserId(userId);
+            if (wechatMapper.insert(user) > 0) {
+                log.info("用户注册成功");
+            } else {
+                log.error("用户注册失败");
+            }
+        } else {
+            // 老用户:如本次登录携带了新的昵称或头像,与库中不同则更新
+            boolean needUpdate = false;
+            if (StringUtils.isNotBlank(wechatBo.getNickname()) && !wechatBo.getNickname().equals(user.getNickname())) {
+                user.setNickname(wechatBo.getNickname());
+                log.info("设置昵称完成");
+                needUpdate = true;
+            }
+            if (StringUtils.isNotBlank(wechatBo.getAvatarUrl()) && !wechatBo.getAvatarUrl().equals(user.getAvatarUrl())) {
+                String ossUrl = transferImageToOss(wechatBo.getAvatarUrl());
+                if (StringUtils.isNotBlank(ossUrl)) {
+                    if (ossUrl.length() > 200) {
+                        ossUrl = ossUrl.substring(0, 200);
+                    }
+                    user.setAvatarUrl(ossUrl);
+                    needUpdate = true;
+                }
+            }
+            if (needUpdate) {
+                log.info("设置昵称完成  : {}", user.getNickname());
+                int flag = wechatMapper.updateById(user);
+                if (flag > 0) {
+                    log.info("更新用户信息成功");
+                } else {
+                    log.error("更新用户信息失败");
+                }
+            }
+        }
+
+        // 返回这个用户对象
+        return user;
+    }
+
+
+    private String getOpenid(String code) {
+        log.info("微信接口服务,获得当前微信用户的openid{}", code);
+        // 调用微信接口服务,获得当前微信用户的openid
+        log.info("微信接口服务,获得当前微信用户的Appid{}", weChatProperties.getAppid());
+        log.info("微信接口服务,获得当前微信用户的Secret{}", weChatProperties.getSecret());
+
+        Map<String, String> map = new HashMap<>();
+        map.put("appid", weChatProperties.getAppid());
+        map.put("secret", weChatProperties.getSecret());
+        map.put("js_code", code);
+        map.put("grant_type", "authorization_code");
+        String json = HttpClientUtil.doGet(WX_LOGIN, map);
+        log.info("微信接口服务,获得当前微信用户的json{}", json);
+        JSONObject jsonObject = JSON.parseObject(json);
+        String openid = jsonObject.getString("openid");
+        log.info("微信接口服务,获得当前微信用户的openid{}", openid);
+        return openid;
+    }
+
+
+    /**
+     * 将传入的头像地址转存到OSS并返回最终URL
+     * 支持 http(s) 链接 与 data:image/...;base64 格式
+     */
+    private String transferImageToOss(String source) {
+        if (StringUtils.isBlank(source)) {
+            return null;
+        }
+
+        // 如果已经是OSS地址,直接返回
+        if (source.startsWith("https://oss.") || source.contains(".aliyuncs.com") ||
+            source.contains(".qiniucdn.com") || source.contains("hxsjkj.com")) {
+            log.info("头像已是OSS地址,直接使用: {}", source.substring(0, Math.min(50, source.length())));
+            return source;
+        }
+
+        try {
+            byte[] imageBytes;
+            String suffix = ".jpg";
+            String contentType = "image/jpeg";
+
+            // 1) 处理 Base64 格式
+            if (source.startsWith("data:image/")) {
+                log.info("检测到Base64图片,开始解码并上传");
+                int comma = source.indexOf(',');
+                if (comma > 0) {
+                    String meta = source.substring(5, comma); // image/png;base64
+                    if (meta.contains("/")) {
+                        String ext = meta.substring(meta.indexOf('/') + 1);
+                        if (ext.contains(";")) {
+                            ext = ext.substring(0, ext.indexOf(';'));
+                        }
+                        suffix = "." + ext;
+                        contentType = "image/" + ext;
+                    }
+                    String base64 = source.substring(comma + 1);
+                    imageBytes = Base64.getDecoder().decode(base64);
+                } else {
+                    log.warn("Base64格式不正确");
+                    return null;
+                }
+            }
+            // 2) 处理 HTTP(S) 外部链接(微信头像等)
+            else if (source.startsWith("http://") || source.startsWith("https://")) {
+                log.info("检测到外部图片URL,开始下载: {}", source.substring(0, Math.min(50, source.length())));
+                imageBytes = downloadImageBytes(source);
+                if (imageBytes == null || imageBytes.length == 0) {
+                    log.warn("下载图片失败");
+                    return null;
+                }
+                // 根据URL推断扩展名
+                if (source.contains(".png")) {
+                    suffix = ".png";
+                    contentType = "image/png";
+                } else if (source.contains(".webp")) {
+                    suffix = ".webp";
+                    contentType = "image/webp";
+                } else if (source.contains(".gif")) {
+                    suffix = ".gif";
+                    contentType = "image/gif";
+                }
+            } else {
+                log.warn("不支持的图片格式: {}", source.substring(0, Math.min(50, source.length())));
+                return null;
+            }
+
+            // 3) 上传到 OSS
+            if (imageBytes == null || imageBytes.length == 0) {
+                log.warn("图片字节为空,无法上传");
+                return null;
+            }
+
+            log.info("开始上传图片到OSS,大小: {} bytes", imageBytes.length);
+            OssClient storage = OssFactory.instance();
+            UploadResult uploadResult = storage.uploadSuffix(imageBytes, suffix, contentType);
+            String ossUrl = uploadResult.getUrl();
+            log.info("图片上传成功,OSS URL: {}", ossUrl);
+            return ossUrl;
+
+        } catch (Exception e) {
+            log.error("头像转存OSS失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 下载网络图片为字节数组
+     */
+    private byte[] downloadImageBytes(String url) {
+        try {
+            RestTemplate restTemplate = new RestTemplate();
+            ResponseEntity<byte[]> response = restTemplate.getForEntity(url, byte[].class);
+            if (response.getStatusCode().is2xxSuccessful()) {
+                return response.getBody();
+            }
+            log.warn("下载图片失败,状态码: {}", response.getStatusCode());
+            return null;
+        } catch (Exception e) {
+            log.error("下载图片异常: {}", url, e);
+            return null;
+        }
+    }
+
+    private void validEntityBeforeSave(SysUserWechat entity) {
+        //TODO 做一些数据校验,如唯一约束
+    }
+
+    @Override
+    public SysUserWechatVo queryById(Long id) {
+        return wechatMapper.selectVoById(id);
+    }
+
+
+    @Override
+    public Boolean updateByBo(SysUserWechatBo bo) {
+        SysUserWechat update = MapstructUtils.convert(bo, SysUserWechat.class);
+        validEntityBeforeSave(update);
+        return wechatMapper.updateById(update) > 0;
+    }
+
+    @Override
+    public SysUserWechat queryByPhoneNumber(String phoneNumber) {
+        LambdaQueryWrapper<SysUserWechat> lqw =
+            new LambdaQueryWrapper<SysUserWechat>().eq(SysUserWechat::getPhoneNumber, phoneNumber);
+        return wechatMapper.selectOne(lqw);
+    }
+
+
+}

+ 184 - 0
ruoyi-auth/src/main/java/org/dromara/auth/util/HttpClientUtil.java

@@ -0,0 +1,184 @@
+package org.dromara.auth.util;
+
+import com.alibaba.fastjson.JSONObject;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Http工具类
+ */
+public class HttpClientUtil {
+
+    static final int TIMEOUT_MSEC = 5 * 1000;
+
+    /**
+     * 发送GET方式请求
+     *
+     * @param url
+     * @param paramMap
+     * @return
+     */
+    public static String doGet(String url, Map<String, String> paramMap) {
+        // 创建Httpclient对象
+        CloseableHttpClient httpClient = HttpClients.createDefault();
+
+        String result = "";
+        CloseableHttpResponse response = null;
+
+        try {
+            URIBuilder builder = new URIBuilder(url);
+            if (paramMap != null) {
+                for (String key : paramMap.keySet()) {
+                    builder.addParameter(key, paramMap.get(key));
+                }
+            }
+            URI uri = builder.build();
+
+            //创建GET请求
+            HttpGet httpGet = new HttpGet(uri);
+
+            //发送请求
+            response = httpClient.execute(httpGet);
+
+            //判断响应状态
+            if (response.getStatusLine().getStatusCode() == 200) {
+                result = EntityUtils.toString(response.getEntity(), "UTF-8");
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            try {
+                response.close();
+                httpClient.close();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * 发送POST方式请求
+     *
+     * @param url
+     * @param paramMap
+     * @return
+     * @throws IOException
+     */
+    public static String doPost(String url, Map<String, String> paramMap) throws IOException {
+        // 创建Httpclient对象
+        CloseableHttpClient httpClient = HttpClients.createDefault();
+        CloseableHttpResponse response = null;
+        String resultString = "";
+
+        try {
+            // 创建Http Post请求
+            HttpPost httpPost = new HttpPost(url);
+
+            // 创建参数列表
+            if (paramMap != null) {
+                List<NameValuePair> paramList = new ArrayList();
+                for (Map.Entry<String, String> param : paramMap.entrySet()) {
+                    paramList.add(new BasicNameValuePair(param.getKey(), param.getValue()));
+                }
+                // 模拟表单
+                UrlEncodedFormEntity entity = new UrlEncodedFormEntity(paramList);
+                httpPost.setEntity(entity);
+            }
+
+            httpPost.setConfig(builderRequestConfig());
+
+            // 执行http请求
+            response = httpClient.execute(httpPost);
+
+            resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
+        } catch (Exception e) {
+            throw e;
+        } finally {
+            try {
+                response.close();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+
+        return resultString;
+    }
+
+    /**
+     * 发送POST方式请求
+     *
+     * @param url
+     * @param paramMap
+     * @return
+     * @throws IOException
+     */
+    public static String doPost4Json(String url, Map<String, String> paramMap) throws IOException {
+        // 创建Httpclient对象
+        CloseableHttpClient httpClient = HttpClients.createDefault();
+        CloseableHttpResponse response = null;
+        String resultString = "";
+
+        try {
+            // 创建Http Post请求
+            HttpPost httpPost = new HttpPost(url);
+
+            if (paramMap != null) {
+                //构造json格式数据
+                JSONObject jsonObject = new JSONObject();
+                for (Map.Entry<String, String> param : paramMap.entrySet()) {
+                    jsonObject.put(param.getKey(), param.getValue());
+                }
+                StringEntity entity = new StringEntity(jsonObject.toString(), "utf-8");
+                //设置请求编码
+                entity.setContentEncoding("utf-8");
+                //设置数据类型
+                entity.setContentType("application/json");
+                httpPost.setEntity(entity);
+            }
+
+            httpPost.setConfig(builderRequestConfig());
+
+            // 执行http请求
+            response = httpClient.execute(httpPost);
+
+            resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
+        } catch (Exception e) {
+            throw e;
+        } finally {
+            try {
+                response.close();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+
+        return resultString;
+    }
+
+    private static RequestConfig builderRequestConfig() {
+        return RequestConfig.custom()
+            .setConnectTimeout(TIMEOUT_MSEC)
+            .setConnectionRequestTimeout(TIMEOUT_MSEC)
+            .setSocketTimeout(TIMEOUT_MSEC).build();
+    }
+
+}
+

+ 19 - 1
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/pc/PcSysUserController.java

@@ -10,7 +10,6 @@ import org.dromara.common.redis.utils.RedisUtils;
 import org.dromara.common.satoken.utils.LoginHelper;
 import org.dromara.common.web.core.BaseController;
 import org.dromara.system.domain.bo.ChangeUserPwdBo;
-import org.dromara.system.domain.bo.SysUserBo;
 import org.dromara.system.domain.vo.SysUserVo;
 import org.dromara.system.service.ISysUserService;
 import org.springframework.validation.annotation.Validated;
@@ -69,4 +68,23 @@ public class PcSysUserController extends BaseController {
         user.setPassword(BCrypt.hashpw(user.getPassword()));
         return toAjax(sysUserService.resetUserPwd(user.getUserId(), user.getPassword()));
     }
+
+    @PutMapping("/normalChangePwd")
+    public R<Void> normalChangePwd(@RequestBody ChangeUserPwdBo user) {
+        Long userId = LoginHelper.getLoginUser().getUserId();
+        user.setUserId(userId);
+        SysUserVo sysUserVo = sysUserService.selectUserById(user.getUserId());
+        if (!BCrypt.checkpw(user.getOldPassword(), sysUserVo.getPassword())) {
+            throw new ServiceException("旧密码错误");
+        }
+
+        //校验密码与确认密码是否一致
+        if (!user.getPassword().equals(user.getConfirmPassword())) {
+            throw new ServiceException("密码与确认密码不一致");
+        }
+        sysUserService.checkUserAllowed(user.getUserId());
+        sysUserService.checkUserDataScope(user.getUserId());
+        user.setPassword(BCrypt.hashpw(user.getPassword()));
+        return toAjax(sysUserService.resetUserPwd(user.getUserId(), user.getPassword()));
+    }
 }

+ 5 - 0
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/ChangeUserPwdBo.java

@@ -20,6 +20,11 @@ public class ChangeUserPwdBo {
      */
     private String code;
 
+    /**
+     * 输入的旧密码
+     */
+    private String oldPassword;
+
     /**
      * 密码
      */