Эх сурвалжийг харах

微信小程序用户登录功能(登录加手机验证)

panzhilin 2 сар өмнө
parent
commit
ac68564695

+ 2 - 1
alien-dining/src/main/java/shop/alien/dining/config/SwaggerConfig.java

@@ -33,7 +33,8 @@ public class SwaggerConfig {
                 .apiInfo(apiInfo())
                 .groupName("微信点餐Api服务")
                 .select()
-                .apis(RequestHandlerSelectors.any())
+                // 只扫描点餐模块的 Controller 包,避免展示系统默认的错误控制器等无关接口
+                .apis(RequestHandlerSelectors.basePackage("shop.alien.dining.controller"))
                 .paths(PathSelectors.any())
                 .build();
     }

+ 114 - 0
alien-dining/src/main/java/shop/alien/dining/controller/DiningUserController.java

@@ -0,0 +1,114 @@
+package shop.alien.dining.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiSort;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.dining.dto.ChangePhoneDto;
+import shop.alien.dining.dto.UserProfileUpdateDto;
+import shop.alien.dining.dto.WeChatLoginDto;
+import shop.alien.dining.service.DiningUserService;
+import shop.alien.dining.vo.DiningUserVo;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.validation.Valid;
+
+/**
+ * 点餐用户控制器
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+@Slf4j
+@Api(tags = {"微信点餐-登录(用户端)"})
+@ApiSort(2)
+@CrossOrigin
+@RestController
+@RequestMapping("/dining/user")
+@RequiredArgsConstructor
+public class DiningUserController {
+
+    private final DiningUserService diningUserService;
+
+    @ApiOperation(value = "微信小程序登录", notes = "仅通过 wx.getPhoneNumber() 的 phoneCode 登录,无需 wx.login。后端调用 phonenumber.getPhoneNumber 换取手机号。")
+    @PostMapping("/wechatLogin")
+    public R<DiningUserVo> wechatLogin(@RequestBody WeChatLoginDto loginDto, HttpServletRequest request) {
+        log.info("微信小程序登录: phoneCode={}, storeId={}",
+                loginDto.getPhoneCode(),
+                loginDto.getStoreId());
+
+        if (loginDto.getPhoneCode() == null || loginDto.getPhoneCode().trim().isEmpty()) {
+            return R.fail("phoneCode不能为空,请通过 wx.getPhoneNumber() 获取");
+        }
+
+        String macIp = getClientIp(request);
+        DiningUserVo userVo = diningUserService.wechatLogin(
+                loginDto.getPhoneCode(),
+                loginDto.getStoreId(),
+                macIp);
+        if (userVo == null) {
+            return R.fail("登录失败,请先授权手机号(phoneCode 有效期5分钟,仅能使用一次)");
+        }
+        return R.data(userVo);
+    }
+
+    @ApiOperation(value = "获取用户信息", notes = "根据用户ID获取用户详细信息")
+    @GetMapping("/getUserInfo")
+    public R<DiningUserVo> getUserInfo(@RequestParam("userId") Long userId) {
+        log.info("获取用户信息: userId={}", userId);
+
+        if (userId == null) {
+            return R.fail("用户ID不能为空");
+        }
+        DiningUserVo userVo = diningUserService.getUserInfo(userId);
+        if (userVo == null) {
+            return R.fail("用户不存在或状态异常");
+        }
+        return R.data(userVo);
+    }
+
+    @ApiOperation(value = "更新用户个人信息", notes = "登录后完善资料,支持更新昵称、头像、性别、生日等")
+    @PostMapping("/updateProfile")
+    public R<DiningUserVo> updateProfile(@Valid @RequestBody UserProfileUpdateDto dto) {
+        log.info("更新用户个人信息: userId={}", dto.getUserId());
+
+        DiningUserVo userVo = diningUserService.updateProfile(dto);
+        if (userVo == null) {
+            return R.fail("更新失败,用户不存在");
+        }
+        return R.data(userVo);
+    }
+
+    @ApiOperation(value = "更换手机号", notes = "校验验证码后更新 user_phone,成功后需重新登录")
+    @PostMapping("/changePhone")
+    public R<DiningUserVo> changePhone(@Valid @RequestBody ChangePhoneDto dto) {
+        log.info("更换手机号: userId={}, newPhone={}", dto.getUserId(), dto.getNewPhone());
+
+        DiningUserVo userVo = diningUserService.changePhone(dto);
+        if (userVo == null) {
+            return R.fail("更换失败,请检查验证码或用户状态");
+        }
+        return R.data(userVo);
+    }
+
+    /**
+     * 获取客户端IP地址
+     */
+    private String getClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("Proxy-Client-IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("WL-Proxy-Client-IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        return ip;
+    }
+}

+ 34 - 0
alien-dining/src/main/java/shop/alien/dining/dto/ChangePhoneDto.java

@@ -0,0 +1,34 @@
+package shop.alien.dining.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
+/**
+ * 更换手机号请求
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+@Data
+@ApiModel("更换手机号请求")
+public class ChangePhoneDto {
+
+    @ApiModelProperty(value = "用户ID", required = true)
+    @NotNull(message = "用户ID不能为空")
+    private Long userId;
+
+    @ApiModelProperty(value = "新手机号", required = true)
+    @NotBlank(message = "新手机号不能为空")
+    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
+    private String newPhone;
+
+    @ApiModelProperty(value = "验证码", required = true)
+    @NotBlank(message = "验证码不能为空")
+    private String code;
+}

+ 56 - 0
alien-dining/src/main/java/shop/alien/dining/dto/UserProfileUpdateDto.java

@@ -0,0 +1,56 @@
+package shop.alien.dining.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.util.Date;
+
+/**
+ * 用户个人信息更新DTO
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+@Data
+@ApiModel("用户个人信息更新请求")
+public class UserProfileUpdateDto {
+
+    @ApiModelProperty(value = "用户ID", required = true)
+    @NotNull(message = "用户ID不能为空")
+    private Long userId;
+
+    @ApiModelProperty(value = "昵称")
+    private String nickName;
+
+    @ApiModelProperty(value = "头像URL")
+    private String avatarUrl;
+
+    @ApiModelProperty(value = "性别(男/女)")
+    private String gender;
+
+    @ApiModelProperty(value = "生日", example = "1990-01-01")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date birthday;
+
+    @ApiModelProperty(value = "真实姓名")
+    private String realName;
+
+    @ApiModelProperty(value = "省份")
+    private String province;
+
+    @ApiModelProperty(value = "城市")
+    private String city;
+
+    @ApiModelProperty(value = "区县")
+    private String district;
+
+    @ApiModelProperty(value = "详细地址")
+    private String address;
+
+    @ApiModelProperty(value = "个人简介")
+    private String jianjie;
+}

+ 26 - 0
alien-dining/src/main/java/shop/alien/dining/dto/WeChatLoginDto.java

@@ -0,0 +1,26 @@
+package shop.alien.dining.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 微信小程序登录DTO(新方式:仅 getPhoneNumber,无需 wx.login)
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+@Data
+@ApiModel("微信小程序登录请求")
+public class WeChatLoginDto {
+
+    @ApiModelProperty(value = "手机号凭证 code(通过 wx.getPhoneNumber() 获取,必填)", required = true)
+    @NotBlank(message = "phoneCode不能为空")
+    private String phoneCode;
+
+    @ApiModelProperty(value = "店铺ID", required = false)
+    private Long storeId;
+}

+ 32 - 0
alien-dining/src/main/java/shop/alien/dining/feign/AlienStoreFeign.java

@@ -0,0 +1,32 @@
+package shop.alien.dining.feign;
+
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import shop.alien.entity.result.R;
+
+/**
+ * 点餐模块调用 alien-store(发码、校验等)
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+@FeignClient(name = "alienStoreFeign", url = "${feign.alienStore.url:}")
+public interface AlienStoreFeign {
+
+    /**
+     * 校验短信验证码(复用 store 阿里云短信)
+     *
+     * @param phone        手机号
+     * @param appType      0:用户 1:商家 2:商家web
+     * @param businessType 3:修改手机号
+     * @param code         验证码
+     */
+    @GetMapping("ali/checkSmsCode")
+    R checkSmsCode(
+            @RequestParam("phone") String phone,
+            @RequestParam("appType") Integer appType,
+            @RequestParam("businessType") Integer businessType,
+            @RequestParam("code") Integer code);
+}

+ 49 - 0
alien-dining/src/main/java/shop/alien/dining/service/DiningUserService.java

@@ -0,0 +1,49 @@
+package shop.alien.dining.service;
+
+import shop.alien.dining.dto.ChangePhoneDto;
+import shop.alien.dining.dto.UserProfileUpdateDto;
+import shop.alien.dining.vo.DiningUserVo;
+
+/**
+ * 点餐用户服务接口
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+public interface DiningUserService {
+
+    /**
+     * 微信小程序登录(新方式:仅 phoneCode,无需 wx.login)
+     *
+     * @param phoneCode 手机号凭证(通过 wx.getPhoneNumber() 获取,必填)
+     * @param storeId   店铺ID(可选)
+     * @param macIp     客户端IP地址
+     * @return 用户信息(包含 token)
+     */
+    DiningUserVo wechatLogin(String phoneCode, Long storeId, String macIp);
+
+    /**
+     * 更新用户个人信息
+     *
+     * @param dto 用户信息更新DTO
+     * @return 更新后的用户信息
+     */
+    DiningUserVo updateProfile(UserProfileUpdateDto dto);
+
+    /**
+     * 获取用户信息
+     *
+     * @param userId 用户ID
+     * @return 用户信息
+     */
+    DiningUserVo getUserInfo(Long userId);
+
+    /**
+     * 更换手机号:Feign 调 store 校验验证码后更新 user_phone
+     *
+     * @param dto 更换手机号请求
+     * @return 更新后的用户信息,失败返回 null
+     */
+    DiningUserVo changePhone(ChangePhoneDto dto);
+}

+ 320 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/DiningUserServiceImpl.java

@@ -0,0 +1,320 @@
+package shop.alien.dining.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import shop.alien.dining.config.BaseRedisService;
+import shop.alien.dining.dto.ChangePhoneDto;
+import shop.alien.dining.dto.UserProfileUpdateDto;
+import shop.alien.dining.feign.AlienStoreFeign;
+import shop.alien.dining.service.DiningUserService;
+import shop.alien.dining.util.WeChatMiniProgramUtil;
+import shop.alien.entity.result.R;
+import shop.alien.dining.vo.DiningUserVo;
+import shop.alien.entity.store.LifeUser;
+import shop.alien.entity.store.vo.LifeUserVo;
+import shop.alien.mapper.LifeUserMapper;
+import shop.alien.util.common.JwtUtil;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 点餐用户服务实现类(新方式:仅 phoneCode,无需 wx.login)
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DiningUserServiceImpl implements DiningUserService {
+
+    private final LifeUserMapper lifeUserMapper;
+    private final BaseRedisService baseRedisService;
+    private final WeChatMiniProgramUtil weChatMiniProgramUtil;
+    private final AlienStoreFeign alienStoreFeign;
+
+    @Value("${jwt.expiration-time}")
+    private String effectiveTime;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public DiningUserVo wechatLogin(String phoneCode, Long storeId, String macIp) {
+        // 1. 通过 phonenumber.getPhoneNumber 换取手机号(必填)
+        String phone = weChatMiniProgramUtil.getPhoneNumberByCode(phoneCode);
+        if (StringUtils.isBlank(phone)) {
+            log.warn("登录失败:无法通过 phoneCode 获取手机号(请先授权手机号,code 有效期5分钟且仅能使用一次)");
+            return null;
+        }
+        log.info("成功获取手机号: {}", phone.substring(0, Math.min(7, phone.length())) + "****");
+
+        // 2. 记录店铺ID(如果提供)
+        if (storeId != null) {
+            log.info("登录请求包含店铺ID: storeId={}", storeId);
+        }
+
+        // 3. 根据手机号查询用户
+        LambdaQueryWrapper<LifeUser> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(LifeUser::getUserPhone, phone);
+        LifeUser user = lifeUserMapper.selectOne(queryWrapper);
+
+        // 4. 用户不存在则创建
+        if (user == null) {
+            user = new LifeUser();
+            user.setUserPhone(phone);
+            user.setUserName(phone);
+            user.setRealName(phone);
+            user.setCreatedTime(new Date());
+            int ret = lifeUserMapper.insert(user);
+            if (ret != 1) {
+                log.error("创建用户失败");
+                return null;
+            }
+            queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.eq(LifeUser::getUserPhone, phone);
+            user = lifeUserMapper.selectOne(queryWrapper);
+            log.info("创建新用户: phone={}, userId={}", phone, user.getId());
+        } else {
+            log.info("用户已存在,直接登录: phone={}, userId={}", phone, user.getId());
+        }
+
+        // 5. 检查用户状态
+        if (user.getIsBanned() != null && user.getIsBanned() == 1) {
+            log.warn("用户已被封禁: userId={}", user.getId());
+            return null;
+        }
+        if (user.getLogoutFlag() != null && user.getLogoutFlag() == 1) {
+            log.warn("用户已注销: userId={}", user.getId());
+            return null;
+        }
+
+        // 6. 生成 token(不含 openid,新方式不做 code2Session)
+        Map<String, String> tokenMap = new HashMap<>();
+        tokenMap.put("phone", user.getUserPhone());
+        tokenMap.put("userName", user.getUserName() != null ? user.getUserName() : "用户");
+        tokenMap.put("userId", user.getId().toString());
+        tokenMap.put("userType", "user");
+        String token = generateToken(user.getUserPhone(), user.getUserName() != null ? user.getUserName() : "用户", tokenMap);
+
+        // 7. 存入 Redis
+        baseRedisService.setString("user_" + user.getUserPhone(), token);
+
+        // 8. 构建返回
+        LifeUserVo userVo = new LifeUserVo();
+        BeanUtils.copyProperties(user, userVo);
+        userVo.setToken(token);
+
+        DiningUserVo diningUserVo = new DiningUserVo();
+        diningUserVo.setId(user.getId().longValue());
+        diningUserVo.setPhone(user.getUserPhone());
+        diningUserVo.setNickName(user.getUserName());
+        diningUserVo.setAvatarUrl(user.getUserImage());
+        diningUserVo.setStatus(0);
+        diningUserVo.setCreatedTime(user.getCreatedTime());
+        diningUserVo.setToken(token);
+
+        return diningUserVo;
+    }
+
+    private String generateToken(String userId, String userName, Map<String, String> tokenMap) {
+        int effectiveTimeInt = Integer.parseInt(effectiveTime.substring(0, effectiveTime.length() - 1));
+        String effectiveTimeUnit = effectiveTime.substring(effectiveTime.length() - 1);
+        long effectiveTimeIntLong = 0L;
+        switch (effectiveTimeUnit) {
+            case "s":
+                effectiveTimeIntLong = effectiveTimeInt * 1000L;
+                break;
+            case "m":
+                effectiveTimeIntLong = effectiveTimeInt * 60L * 1000L;
+                break;
+            case "h":
+                effectiveTimeIntLong = effectiveTimeInt * 60L * 60L * 1000L;
+                break;
+            case "d":
+                effectiveTimeIntLong = effectiveTimeInt * 24L * 60L * 60L * 1000L;
+                break;
+            default:
+                effectiveTimeIntLong = effectiveTimeInt * 24L * 60L * 60L * 1000L;
+        }
+        return JwtUtil.createJWT("user_" + userId, userName, JSONObject.toJSONString(tokenMap), effectiveTimeIntLong);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public DiningUserVo updateProfile(UserProfileUpdateDto dto) {
+        // 1. 查询用户是否存在
+        LifeUser user = lifeUserMapper.selectById(dto.getUserId().intValue());
+        if (user == null) {
+            log.warn("更新个人信息失败:用户不存在, userId={}", dto.getUserId());
+            return null;
+        }
+
+        // 2. 更新用户信息(只更新非空字段)
+        if (StringUtils.isNotBlank(dto.getNickName())) {
+            user.setUserName(dto.getNickName());
+        }
+        if (StringUtils.isNotBlank(dto.getAvatarUrl())) {
+            user.setUserImage(dto.getAvatarUrl());
+        }
+        if (StringUtils.isNotBlank(dto.getGender())) {
+            user.setUserSex(dto.getGender());
+        }
+        if (dto.getBirthday() != null) {
+            user.setUserBirthday(dto.getBirthday());
+        }
+        if (StringUtils.isNotBlank(dto.getRealName())) {
+            user.setRealName(dto.getRealName());
+        }
+        if (StringUtils.isNotBlank(dto.getProvince())) {
+            user.setProvince(dto.getProvince());
+        }
+        if (StringUtils.isNotBlank(dto.getCity())) {
+            user.setCity(dto.getCity());
+        }
+        if (StringUtils.isNotBlank(dto.getDistrict())) {
+            user.setDistrict(dto.getDistrict());
+        }
+        if (StringUtils.isNotBlank(dto.getAddress())) {
+            user.setAddress(dto.getAddress());
+        }
+        if (StringUtils.isNotBlank(dto.getJianjie())) {
+            user.setJianjie(dto.getJianjie());
+        }
+
+        // 3. 设置更新时间
+        user.setUpdatedTime(new Date());
+
+        // 4. 执行更新
+        int result = lifeUserMapper.updateById(user);
+        if (result != 1) {
+            log.error("更新用户信息失败, userId={}", dto.getUserId());
+            return null;
+        }
+        log.info("用户信息更新成功, userId={}", dto.getUserId());
+
+        // 5. 返回更新后的用户信息
+        return buildDiningUserVo(user);
+    }
+
+    @Override
+    public DiningUserVo getUserInfo(Long userId) {
+        // 1. 查询用户
+        LifeUser user = lifeUserMapper.selectById(userId.intValue());
+        if (user == null) {
+            log.warn("获取用户信息失败:用户不存在, userId={}", userId);
+            return null;
+        }
+
+        // 2. 检查用户状态
+        if (user.getIsBanned() != null && user.getIsBanned() == 1) {
+            log.warn("用户已被封禁: userId={}", userId);
+            return null;
+        }
+        if (user.getLogoutFlag() != null && user.getLogoutFlag() == 1) {
+            log.warn("用户已注销: userId={}", userId);
+            return null;
+        }
+
+        // 3. 返回用户信息
+        return buildDiningUserVo(user);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public DiningUserVo changePhone(ChangePhoneDto dto) {
+        String newPhone = dto.getNewPhone().trim();
+        String codeStr = dto.getCode().trim();
+
+        // 1. 校验验证码(Feign 调 store ali/checkSmsCode,appType=0 用户端,businessType=3 修改手机号)
+        int codeInt;
+        try {
+            codeInt = Integer.parseInt(codeStr);
+        } catch (NumberFormatException e) {
+            log.warn("更换手机号失败:验证码格式错误, userId={}, newPhone={}", dto.getUserId(), newPhone);
+            return null;
+        }
+        R checkRes = alienStoreFeign.checkSmsCode(newPhone, 0, 3, codeInt);
+        if (!R.isSuccess(checkRes)) {
+            log.warn("更换手机号失败:验证码错误或已过期, userId={}, newPhone={}", dto.getUserId(), newPhone);
+            return null;
+        }
+
+        // 2. 查询用户
+        LifeUser user = lifeUserMapper.selectById(dto.getUserId().intValue());
+        if (user == null) {
+            log.warn("更换手机号失败:用户不存在, userId={}", dto.getUserId());
+            return null;
+        }
+        if (user.getIsBanned() != null && user.getIsBanned() == 1) {
+            log.warn("更换手机号失败:用户已被封禁, userId={}", dto.getUserId());
+            return null;
+        }
+        if (user.getLogoutFlag() != null && user.getLogoutFlag() == 1) {
+            log.warn("更换手机号失败:用户已注销, userId={}", dto.getUserId());
+            return null;
+        }
+
+        // 3. 新手机号与当前相同则无需更新
+        if (newPhone.equals(user.getUserPhone())) {
+            log.info("新手机号与当前相同,无需更换, userId={}", dto.getUserId());
+            return buildDiningUserVo(user);
+        }
+
+        // 4. 新手机号是否已被其他用户使用
+        LambdaQueryWrapper<LifeUser> q = new LambdaQueryWrapper<>();
+        q.eq(LifeUser::getUserPhone, newPhone);
+        LifeUser existing = lifeUserMapper.selectOne(q);
+        if (existing != null && !existing.getId().equals(user.getId())) {
+            log.warn("更换手机号失败:新手机号已被其他用户使用, newPhone={}", newPhone);
+            return null;
+        }
+
+        // 5. 更新 user_phone
+        String oldPhone = user.getUserPhone();
+        user.setUserPhone(newPhone);
+        user.setUpdatedTime(new Date());
+        int n = lifeUserMapper.updateById(user);
+        if (n != 1) {
+            log.error("更换手机号失败:更新数据库异常, userId={}", dto.getUserId());
+            return null;
+        }
+
+        // 6. 清除旧手机号对应 token,强制重新登录
+        baseRedisService.delete("user_" + oldPhone);
+        log.info("更换手机号成功, userId={}, oldPhone={}, newPhone={}", dto.getUserId(), oldPhone, newPhone);
+
+        return buildDiningUserVo(user);
+    }
+
+    /**
+     * 构建 DiningUserVo
+     */
+    private DiningUserVo buildDiningUserVo(LifeUser user) {
+        DiningUserVo diningUserVo = new DiningUserVo();
+        diningUserVo.setId(user.getId().longValue());
+        diningUserVo.setPhone(user.getUserPhone());
+        diningUserVo.setNickName(user.getUserName());
+        diningUserVo.setAvatarUrl(user.getUserImage());
+        diningUserVo.setStatus(0);
+        diningUserVo.setCreatedTime(user.getCreatedTime());
+        // 补充更多字段
+        diningUserVo.setGender(user.getUserSex());
+        diningUserVo.setBirthday(user.getUserBirthday());
+        diningUserVo.setRealName(user.getRealName());
+        diningUserVo.setProvince(user.getProvince());
+        diningUserVo.setCity(user.getCity());
+        diningUserVo.setDistrict(user.getDistrict());
+        diningUserVo.setAddress(user.getAddress());
+        diningUserVo.setJianjie(user.getJianjie());
+        return diningUserVo;
+    }
+}

+ 345 - 0
alien-dining/src/main/java/shop/alien/dining/util/WeChatMiniProgramUtil.java

@@ -0,0 +1,345 @@
+package shop.alien.dining.util;
+
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import shop.alien.util.encryption.StandardAesUtil;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 微信小程序工具类
+ * 用于调用微信小程序相关接口
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+@Slf4j
+@Component
+public class WeChatMiniProgramUtil {
+
+    @Value("${wechat.miniprogram.appId}")
+    private String appId;
+
+    @Value("${wechat.miniprogram.appSecret}")
+    private String appSecret;
+
+    /**
+     * 微信小程序登录接口地址
+     */
+    private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
+
+    /**
+     * 微信小程序手机号获取接口地址(新版本)
+     */
+    private static final String CODE2VERIFYINFO_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber";
+
+    /**
+     * 通过 code 获取 openid 和 session_key
+     *
+     * @param code 小程序端通过 wx.login() 获取的 code
+     * @return WeChatSessionInfo 包含 openid、session_key、unionid 等信息
+     */
+    public WeChatSessionInfo code2Session(String code) {
+        String url = String.format("%s?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
+                CODE2SESSION_URL, appId, appSecret, code);
+
+        HttpClient httpClient = HttpClients.createDefault();
+        HttpGet httpGet = new HttpGet(url);
+
+        try {
+            HttpResponse response = httpClient.execute(httpGet);
+            String responseBody = EntityUtils.toString(response.getEntity(), "UTF-8");
+            log.info("微信 code2session 响应: {}", responseBody);
+
+            JSONObject jsonObject = JSONObject.parseObject(responseBody);
+
+            // 检查是否有错误
+            if (jsonObject.containsKey("errcode")) {
+                Integer errcode = jsonObject.getInteger("errcode");
+                String errmsg = jsonObject.getString("errmsg");
+                
+                // 将错误信息保存到返回对象中,方便上层处理
+                WeChatSessionInfo errorInfo = new WeChatSessionInfo();
+                errorInfo.setErrcode(errcode);
+                errorInfo.setErrmsg(errmsg);
+                
+                // 根据错误码记录详细的错误信息
+                String errorDetail = getErrorDetail(errcode, errmsg);
+                log.error("微信 code2session 失败: errcode={}, errmsg={}, 详情={}", errcode, errmsg, errorDetail);
+                return errorInfo;
+            }
+
+            // 解析返回数据
+            WeChatSessionInfo sessionInfo = new WeChatSessionInfo();
+            sessionInfo.setOpenid(jsonObject.getString("openid"));
+            sessionInfo.setSessionKey(jsonObject.getString("session_key"));
+            sessionInfo.setUnionid(jsonObject.getString("unionid"));
+
+            return sessionInfo;
+        } catch (IOException e) {
+            log.error("调用微信 code2session 接口异常", e);
+            return null;
+        }
+    }
+
+    /**
+     * 获取微信错误码的详细说明
+     */
+    private String getErrorDetail(Integer errcode, String errmsg) {
+        if (errcode == null) {
+            return "未知错误";
+        }
+        
+        switch (errcode) {
+            case 40029:
+                return "code无效(可能原因:1. code已使用过,每个code只能使用一次;2. code已过期,有效期5分钟;3. code格式错误;4. appId或appSecret配置错误)";
+            case 45011:
+                return "频率限制,每个用户每分钟最多调用5次";
+            case 40226:
+                return "高风险等级用户,需要用户进行验证";
+            case 40013:
+                return "appId无效,请检查Nacos配置中的wechat.miniprogram.appId";
+            case 40125:
+                return "appSecret无效,请检查Nacos配置中的wechat.miniprogram.appSecret";
+            case -1:
+                return "系统繁忙,请稍后重试";
+            default:
+                return String.format("微信接口错误,错误码:%d,错误信息:%s", errcode, errmsg);
+        }
+    }
+
+    /**
+     * 解密微信小程序加密数据(手机号等)
+     * 使用 AES-128-CBC 算法,使用 session_key 作为密钥
+     *
+     * @param encryptedData 加密数据(Base64编码)
+     * @param sessionKey    session_key(Base64编码)
+     * @param iv           初始向量(Base64编码)
+     * @return 解密后的JSON字符串,包含手机号等信息
+     */
+    public String decryptData(String encryptedData, String sessionKey, String iv) {
+        try {
+            if (encryptedData == null || encryptedData.isEmpty()) {
+                log.warn("加密数据为空,无法解密");
+                return null;
+            }
+            if (sessionKey == null || sessionKey.isEmpty()) {
+                log.warn("session_key为空,无法解密");
+                return null;
+            }
+            if (iv == null || iv.isEmpty()) {
+                log.warn("初始向量为空,无法解密");
+                return null;
+            }
+
+            // Base64解码
+            byte[] dataByte = Base64.decodeBase64(encryptedData);
+            byte[] keyByte = Base64.decodeBase64(sessionKey);
+            byte[] ivByte = Base64.decodeBase64(iv);
+
+            // AES-128-CBC 解密
+            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+            SecretKeySpec spec = new SecretKeySpec(keyByte, "AES");
+            IvParameterSpec ivSpec = new IvParameterSpec(ivByte);
+            cipher.init(Cipher.DECRYPT_MODE, spec, ivSpec);
+
+            byte[] decrypted = cipher.doFinal(dataByte);
+            String result = new String(decrypted, StandardCharsets.UTF_8);
+            
+            log.info("微信加密数据解密成功");
+            return result;
+        } catch (Exception e) {
+            log.error("微信加密数据解密失败: encryptedData={}, error={}", 
+                    encryptedData != null ? encryptedData.substring(0, Math.min(20, encryptedData.length())) : "null", 
+                    e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 从解密后的数据中提取手机号(旧版本,已废弃)
+     *
+     * @param decryptedData 解密后的JSON字符串
+     * @return 手机号
+     */
+    @Deprecated
+    public String extractPhoneNumber(String decryptedData) {
+        try {
+            if (decryptedData == null || decryptedData.isEmpty()) {
+                return null;
+            }
+            
+            JSONObject jsonObject = JSONObject.parseObject(decryptedData);
+            String phoneNumber = jsonObject.getString("phoneNumber");
+            
+            log.info("从解密数据中提取手机号: {}", phoneNumber != null ? phoneNumber.substring(0, Math.min(7, phoneNumber.length())) + "****" : "null");
+            return phoneNumber;
+        } catch (Exception e) {
+            log.error("提取手机号失败: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 通过手机号凭证 code 获取手机号(新版本API)
+     * 使用 code2Verifyinfo 接口
+     *
+     * @param phoneCode 手机号凭证 code(通过wx.getPhoneNumber()获取)
+     * @return 手机号,如果获取失败返回null
+     */
+    public String getPhoneNumberByCode(String phoneCode) {
+        if (phoneCode == null || phoneCode.isEmpty()) {
+            log.warn("手机号凭证code为空");
+            return null;
+        }
+
+        try {
+            // 获取 access_token
+            String accessToken = getAccessToken();
+            if (accessToken == null || accessToken.isEmpty()) {
+                log.error("获取access_token失败,无法调用手机号接口");
+                return null;
+            }
+
+            // 构建请求URL
+            String url = String.format("%s?access_token=%s", CODE2VERIFYINFO_URL, accessToken);
+            
+            // 构建请求体
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("code", phoneCode);
+
+            // 使用HttpClient发送POST请求
+            HttpClient httpClient = HttpClients.createDefault();
+            HttpPost httpPost = new HttpPost(url);
+            httpPost.setHeader("Content-Type", "application/json");
+            httpPost.setEntity(new StringEntity(requestBody.toJSONString(), "UTF-8"));
+
+            HttpResponse response = httpClient.execute(httpPost);
+            String responseBody = EntityUtils.toString(response.getEntity(), "UTF-8");
+            log.info("微信 code2Verifyinfo 响应: {}", responseBody);
+
+            JSONObject jsonObject = JSONObject.parseObject(responseBody);
+
+            // 检查是否有错误(errcode 为 0 表示成功,非 0 才视为失败)
+            if (jsonObject.containsKey("errcode")) {
+                Integer errcode = jsonObject.getInteger("errcode");
+                if (errcode != null && errcode != 0) {
+                    String errmsg = jsonObject.getString("errmsg");
+                    log.error("微信 code2Verifyinfo 失败: errcode={}, errmsg={}", errcode, errmsg);
+                    return null;
+                }
+            }
+
+            // 解析返回数据
+            JSONObject phoneInfo = jsonObject.getJSONObject("phone_info");
+            if (phoneInfo != null) {
+                String phoneNumber = phoneInfo.getString("phoneNumber");
+                log.info("成功获取手机号");
+                return phoneNumber;
+            }
+
+            return null;
+        } catch (Exception e) {
+            log.error("调用微信 code2Verifyinfo 接口异常", e);
+            return null;
+        }
+    }
+
+    /**
+     * 获取微信 access_token
+     * TODO: 应该实现token缓存机制,避免频繁请求(access_token有效期为7200秒)
+     */
+    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);
+            
+            HttpClient httpClient = HttpClients.createDefault();
+            HttpGet httpGet = new HttpGet(url);
+            HttpResponse response = httpClient.execute(httpGet);
+            String responseBody = EntityUtils.toString(response.getEntity(), "UTF-8");
+            
+            JSONObject jsonObject = JSONObject.parseObject(responseBody);
+            if (jsonObject.containsKey("access_token")) {
+                String accessToken = jsonObject.getString("access_token");
+                log.debug("成功获取access_token");
+                return accessToken;
+            } else {
+                Integer errcode = jsonObject.getInteger("errcode");
+                String errmsg = jsonObject.getString("errmsg");
+                log.error("获取access_token失败: errcode={}, errmsg={}", errcode, errmsg);
+                return null;
+            }
+        } catch (Exception e) {
+            log.error("获取access_token异常", e);
+            return null;
+        }
+    }
+
+    /**
+     * 微信会话信息
+     */
+    public static class WeChatSessionInfo {
+        private String openid;
+        private String sessionKey;
+        private String unionid;
+        private Integer errcode;
+        private String errmsg;
+
+        public String getOpenid() {
+            return openid;
+        }
+
+        public void setOpenid(String openid) {
+            this.openid = openid;
+        }
+
+        public String getSessionKey() {
+            return sessionKey;
+        }
+
+        public void setSessionKey(String sessionKey) {
+            this.sessionKey = sessionKey;
+        }
+
+        public String getUnionid() {
+            return unionid;
+        }
+
+        public void setUnionid(String unionid) {
+            this.unionid = unionid;
+        }
+
+        public Integer getErrcode() {
+            return errcode;
+        }
+
+        public void setErrcode(Integer errcode) {
+            this.errcode = errcode;
+        }
+
+        public String getErrmsg() {
+            return errmsg;
+        }
+
+        public void setErrmsg(String errmsg) {
+            this.errmsg = errmsg;
+        }
+    }
+}
+

+ 73 - 0
alien-dining/src/main/java/shop/alien/dining/vo/DiningUserVo.java

@@ -0,0 +1,73 @@
+package shop.alien.dining.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 点餐用户VO
+ * 基于 LifeUser 表
+ *
+ * @author ssk
+ * @version 1.0
+ * @date 2024/12/4
+ */
+@Data
+@ApiModel("点餐用户信息")
+public class DiningUserVo {
+
+    @ApiModelProperty("用户ID")
+    private Long id;
+
+    @ApiModelProperty("用户昵称")
+    private String nickName;
+
+    @ApiModelProperty("用户头像")
+    private String avatarUrl;
+
+    @ApiModelProperty("手机号")
+    private String phone;
+
+    @ApiModelProperty("性别")
+    private String gender;
+
+    @ApiModelProperty("生日")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date birthday;
+
+    @ApiModelProperty("真实姓名")
+    private String realName;
+
+    @ApiModelProperty("省份")
+    private String province;
+
+    @ApiModelProperty("城市")
+    private String city;
+
+    @ApiModelProperty("区县")
+    private String district;
+
+    @ApiModelProperty("详细地址")
+    private String address;
+
+    @ApiModelProperty("个人简介")
+    private String jianjie;
+
+    @ApiModelProperty("用户状态:0-正常,1-禁用")
+    private Integer status;
+
+    @ApiModelProperty("创建时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty("最后登录时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date lastLoginTime;
+
+    @ApiModelProperty("JWT Token")
+    private String token;
+}
+