“mitao”
2026-04-07 6ad7a6330add80e0b5a83227888df95e92d7ba87
feat:微信登录
4个文件已添加
6个文件已修改
722 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppWechatAuthProperties.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/controller/AppAuthController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppWechatLoginRequest.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java 115 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppWechatAuthClient.java 250 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppWechatUserProfile.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application.yml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/login.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/service.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppWechatAuthProperties.java
New file
@@ -0,0 +1,87 @@
package com.tcm.training.admin.app.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * APP 微信登录配置。
 */
@Component
@ConfigurationProperties(prefix = "app.auth.wechat")
public class AppWechatAuthProperties {
    private String appId = "";
    private String appSecret = "";
    private String userInfoUrl = "https://api.weixin.qq.com/sns/userinfo";
    private String accessTokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token";
    /**
     * 获取微信开放平台 AppId。
     */
    public String getAppId() {
        return appId;
    }
    /**
     * 设置微信开放平台 AppId。
     */
    public void setAppId(String appId) {
        this.appId = appId;
    }
    /**
     * 获取微信开放平台 AppSecret。
     */
    public String getAppSecret() {
        return appSecret;
    }
    /**
     * 设置微信开放平台 AppSecret。
     */
    public void setAppSecret(String appSecret) {
        this.appSecret = appSecret;
    }
    /**
     * 获取微信用户信息接口地址。
     */
    public String getUserInfoUrl() {
        return userInfoUrl;
    }
    /**
     * 设置微信用户信息接口地址。
     */
    public void setUserInfoUrl(String userInfoUrl) {
        this.userInfoUrl = userInfoUrl;
    }
    /**
     * 获取微信授权换票接口地址。
     */
    public String getAccessTokenUrl() {
        return accessTokenUrl;
    }
    /**
     * 设置微信授权换票接口地址。
     */
    public void setAccessTokenUrl(String accessTokenUrl) {
        this.accessTokenUrl = accessTokenUrl;
    }
    /**
     * 判断是否已配置服务端换票所需参数。
     */
    public boolean isServerAuthConfigured() {
        return hasText(appId) && hasText(appSecret);
    }
    /**
     * 判断字符串是否包含有效文本。
     */
    private boolean hasText(String value) {
        return value != null && !value.trim().isEmpty();
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/controller/AppAuthController.java
@@ -5,6 +5,7 @@
import com.tcm.training.admin.app.auth.dto.AppPasswordResetRequest;
import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest;
import com.tcm.training.admin.app.auth.dto.AppSmsLoginRequest;
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
import com.tcm.training.admin.app.auth.service.AppAuthService;
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse;
@@ -60,6 +61,14 @@
    }
    /**
     * 使用微信授权信息完成 APP 登录。
     */
    @PostMapping("/wechat-login")
    public ApiResponse<AppLoginResponse> wechatLogin(@RequestBody AppWechatLoginRequest request) {
        return ApiResponse.ok(appAuthService.wechatLogin(request));
    }
    /**
     * 发送 APP 忘记密码验证码。
     */
    @PostMapping("/password-reset/code")
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppWechatLoginRequest.java
New file
@@ -0,0 +1,19 @@
package com.tcm.training.admin.app.auth.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
 * APP 微信登录请求。
 */
@Getter
@Setter
@NoArgsConstructor
public class AppWechatLoginRequest {
    private String code;
    private String accessToken;
    private String openid;
    private String unionid;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java
@@ -6,6 +6,7 @@
import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest;
import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest;
import com.tcm.training.admin.app.auth.dto.AppSmsLoginRequest;
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
import com.tcm.training.admin.app.auth.vo.AppLoginUserVO;
import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse;
@@ -17,12 +18,13 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
/**
 * APP 登录认证服务,负责短信登录、密码登录与忘记密码流程。
 * APP 登录认证服务,负责短信登录、密码登录、微信登录与忘记密码流程。
 */
@Service
public class AppAuthService {
@@ -36,6 +38,7 @@
    private final AppSmsCodeService appSmsCodeService;
    private final BCryptPasswordEncoder passwordEncoder;
    private final AppAuthSessionService appAuthSessionService;
    private final AppWechatAuthClient appWechatAuthClient;
    private final StringRedisTemplate stringRedisTemplate;
    /**
@@ -45,11 +48,13 @@
                          AppSmsCodeService appSmsCodeService,
                          BCryptPasswordEncoder passwordEncoder,
                          AppAuthSessionService appAuthSessionService,
                          AppWechatAuthClient appWechatAuthClient,
                          StringRedisTemplate stringRedisTemplate) {
        this.userMapper = userMapper;
        this.appSmsCodeService = appSmsCodeService;
        this.passwordEncoder = passwordEncoder;
        this.appAuthSessionService = appAuthSessionService;
        this.appWechatAuthClient = appWechatAuthClient;
        this.stringRedisTemplate = stringRedisTemplate;
    }
@@ -91,6 +96,25 @@
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new IllegalArgumentException("账号或密码错误");
        }
        return createLoginResponse(user);
    }
    /**
     * 使用微信授权结果完成 APP 登录,首次登录自动创建普通用户。
     */
    public AppLoginResponse wechatLogin(AppWechatLoginRequest request) {
        AppWechatUserProfile profile = appWechatAuthClient.resolveUserProfile(request);
        AppUser user = findUserByWechatOpenid(profile.getOpenid());
        if (user == null) {
            user = buildWechatUser(profile);
            userMapper.insert(user);
            return createLoginResponse(user);
        }
        requireEnabledUser(user);
        // 登录时同步微信专用资料,并仅在本地昵称头像为空时补齐展示字段。
        applyWechatProfile(user, profile, false);
        userMapper.updateById(user);
        return createLoginResponse(user);
    }
@@ -145,7 +169,7 @@
        loginUser.setId(user.getId());
        loginUser.setNickname(resolveNickname(user));
        loginUser.setPhone(user.getPhone());
        loginUser.setAvatar(user.getAvatar());
        loginUser.setAvatar(resolveAvatar(user));
        loginUser.setType(user.getType());
        loginUser.setStatus(user.getStatus());
        loginUser.setClinicId(user.getClinicId());
@@ -172,10 +196,77 @@
     */
    private AppUser requireEnabledUserByPhone(String phone) {
        AppUser user = requireExistingUserByPhone(phone);
        requireEnabledUser(user);
        return user;
    }
    /**
     * 校验给定 APP 用户是否处于启用状态。
     */
    private void requireEnabledUser(AppUser user) {
        if (user.getStatus() == null || user.getStatus() != 1) {
            throw new IllegalArgumentException("账号已被禁用");
        }
    }
    /**
     * 按微信 openid 查询本地 APP 用户。
     */
    private AppUser findUserByWechatOpenid(String openid) {
        return userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getWechatOpenid, openid)
                .eq(AppUser::getDeleted, 0)
                .last("limit 1"));
    }
    /**
     * 基于微信资料构造首次登录时的本地普通用户。
     */
    private AppUser buildWechatUser(AppWechatUserProfile profile) {
        AppUser user = new AppUser();
        user.setType(0);
        user.setStatus(1);
        applyWechatProfile(user, profile, true);
        return user;
    }
    /**
     * 将微信资料回填到本地用户,必要时补齐昵称头像展示字段。
     */
    private void applyWechatProfile(AppUser user, AppWechatUserProfile profile, boolean syncLocalProfile) {
        String nickname = trimToNull(profile.getNickname());
        String avatar = trimToNull(profile.getAvatar());
        user.setWechatOpenid(profile.getOpenid());
        user.setWechatNickname(nickname);
        user.setWechatAvatar(avatar);
        user.setWechatBoundAt(LocalDateTime.now());
        if (syncLocalProfile || trimToNull(user.getNickname()) == null) {
            user.setNickname(nickname == null ? profile.getOpenid() : nickname);
        }
        if (syncLocalProfile || trimToNull(user.getAvatar()) == null) {
            user.setAvatar(avatar);
        }
        if (syncLocalProfile || user.getGender() == null) {
            user.setGender(resolveWechatGender(profile.getSex()));
        }
    }
    /**
     * 将微信性别值转换为本地用户性别枚举。
     */
    private Integer resolveWechatGender(Integer sex) {
        if (sex == null) {
            return null;
        }
        if (sex == 1) {
            return 1;
        }
        if (sex == 2) {
            return 0;
        }
        return null;
    }
    /**
@@ -301,7 +392,25 @@
     */
    private String resolveNickname(AppUser user) {
        String nickname = trimToNull(user.getNickname());
        return nickname == null ? user.getPhone() : nickname;
        if (nickname != null) {
            return nickname;
        }
        String wechatNickname = trimToNull(user.getWechatNickname());
        if (wechatNickname != null) {
            return wechatNickname;
        }
        return user.getPhone();
    }
    /**
     * 优先使用本地头像,缺失时回退到微信头像。
     */
    private String resolveAvatar(AppUser user) {
        String avatar = trimToNull(user.getAvatar());
        if (avatar != null) {
            return avatar;
        }
        return trimToNull(user.getWechatAvatar());
    }
    /**
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppWechatAuthClient.java
New file
@@ -0,0 +1,250 @@
package com.tcm.training.admin.app.auth.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tcm.training.admin.app.auth.config.AppWechatAuthProperties;
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/**
 * 微信登录接口客户端,负责换取授权信息并拉取微信头像昵称。
 */
@Service
public class AppWechatAuthClient {
    private static final Logger log = LoggerFactory.getLogger(AppWechatAuthClient.class);
    private final AppWechatAuthProperties properties;
    private final ObjectMapper objectMapper;
    private final RestOperations restOperations;
    /**
     * 使用默认 HTTP 客户端构造微信认证客户端。
     */
    public AppWechatAuthClient(AppWechatAuthProperties properties, ObjectMapper objectMapper) {
        this(properties, objectMapper, new RestTemplate());
    }
    /**
     * 注入微信配置、JSON 解析器与 HTTP 客户端。
     */
    AppWechatAuthClient(AppWechatAuthProperties properties,
                        ObjectMapper objectMapper,
                        RestOperations restOperations) {
        this.properties = properties;
        this.objectMapper = objectMapper;
        this.restOperations = restOperations;
    }
    /**
     * 按当前请求中的授权信息换取微信用户资料。
     */
    public AppWechatUserProfile resolveUserProfile(AppWechatLoginRequest request) {
        if (request == null) {
            throw new IllegalArgumentException("微信授权信息不完整");
        }
        String accessToken = trimToNull(request.getAccessToken());
        String openid = trimToNull(request.getOpenid());
        String unionid = trimToNull(request.getUnionid());
        if (accessToken != null && openid != null) {
            AppWechatUserProfile profile = fetchUserInfo(accessToken, openid);
            if (profile.getUnionid() == null) {
                profile.setUnionid(unionid);
            }
            return profile;
        }
        String code = trimToNull(request.getCode());
        if (code == null) {
            throw new IllegalArgumentException("微信授权信息不完整");
        }
        if (!properties.isServerAuthConfigured()) {
            throw new IllegalArgumentException("微信登录暂未配置,请稍后重试");
        }
        WechatAccessTokenSnapshot tokenSnapshot = exchangeCode(code);
        AppWechatUserProfile profile = fetchUserInfo(tokenSnapshot.getAccessToken(), tokenSnapshot.getOpenid());
        if (profile.getUnionid() == null) {
            profile.setUnionid(tokenSnapshot.getUnionid());
        }
        return profile;
    }
    /**
     * 使用临时 code 向微信换取 accessToken 与 openid。
     */
    private WechatAccessTokenSnapshot exchangeCode(String code) {
        String url = UriComponentsBuilder.fromHttpUrl(properties.getAccessTokenUrl())
                .queryParam("appid", properties.getAppId())
                .queryParam("secret", properties.getAppSecret())
                .queryParam("code", code)
                .queryParam("grant_type", "authorization_code")
                .build(true)
                .toUriString();
        JsonNode payload = getWechatPayload(url, "微信登录授权失败");
        ensureWechatSuccess(payload, "微信登录授权失败");
        String accessToken = trimToNull(readText(payload, "access_token"));
        String openid = trimToNull(readText(payload, "openid"));
        if (accessToken == null || openid == null) {
            throw new IllegalArgumentException("微信登录授权失败");
        }
        WechatAccessTokenSnapshot snapshot = new WechatAccessTokenSnapshot();
        snapshot.setAccessToken(accessToken);
        snapshot.setOpenid(openid);
        snapshot.setUnionid(trimToNull(readText(payload, "unionid")));
        return snapshot;
    }
    /**
     * 使用微信 accessToken 与 openid 拉取昵称、头像和性别。
     */
    private AppWechatUserProfile fetchUserInfo(String accessToken, String openid) {
        String url = UriComponentsBuilder.fromHttpUrl(properties.getUserInfoUrl())
                .queryParam("access_token", accessToken)
                .queryParam("openid", openid)
                .queryParam("lang", "zh_CN")
                .build(true)
                .toUriString();
        JsonNode payload = getWechatPayload(url, "获取微信用户信息失败");
        ensureWechatSuccess(payload, "获取微信用户信息失败");
        AppWechatUserProfile profile = new AppWechatUserProfile();
        profile.setOpenid(openid);
        profile.setUnionid(trimToNull(readText(payload, "unionid")));
        profile.setNickname(trimToNull(readText(payload, "nickname")));
        profile.setAvatar(trimToNull(readText(payload, "headimgurl")));
        profile.setSex(readInteger(payload, "sex"));
        return profile;
    }
    /**
     * 发起微信接口调用并将响应解析为 JSON。
     */
    private JsonNode getWechatPayload(String url, String failureMessage) {
        try {
            String responseBody = restOperations.getForObject(url, String.class);
            if (responseBody == null || responseBody.trim().isEmpty()) {
                throw new IllegalArgumentException(failureMessage);
            }
            return objectMapper.readTree(responseBody);
        } catch (IllegalArgumentException ex) {
            throw ex;
        } catch (Exception ex) {
            log.error("调用微信接口失败: url={}", url, ex);
            throw new IllegalArgumentException(failureMessage);
        }
    }
    /**
     * 校验微信接口返回是否为成功态。
     */
    private void ensureWechatSuccess(JsonNode payload, String failureMessage) {
        Integer errcode = readInteger(payload, "errcode");
        if (errcode == null || errcode == 0) {
            return;
        }
        String errmsg = trimToNull(readText(payload, "errmsg"));
        if (errmsg == null) {
            errmsg = failureMessage;
        }
        log.warn("微信接口返回异常: errcode={}, errmsg={}", errcode, errmsg);
        throw new IllegalArgumentException(errmsg);
    }
    /**
     * 读取字符串字段。
     */
    private String readText(JsonNode payload, String fieldName) {
        JsonNode valueNode = payload.get(fieldName);
        if (valueNode == null || valueNode.isNull()) {
            return null;
        }
        return valueNode.asText();
    }
    /**
     * 读取整数字段。
     */
    private Integer readInteger(JsonNode payload, String fieldName) {
        String value = trimToNull(readText(payload, fieldName));
        if (value == null) {
            return null;
        }
        try {
            return Integer.valueOf(value);
        } catch (NumberFormatException ex) {
            return null;
        }
    }
    /**
     * 将空白字符串归一化为 null。
     */
    private String trimToNull(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
    /**
     * 保存微信换票结果,避免在多个私有方法之间散落参数。
     */
    private static class WechatAccessTokenSnapshot {
        private String accessToken;
        private String openid;
        private String unionid;
        /**
         * 获取 accessToken。
         */
        public String getAccessToken() {
            return accessToken;
        }
        /**
         * 设置 accessToken。
         */
        public void setAccessToken(String accessToken) {
            this.accessToken = accessToken;
        }
        /**
         * 获取 openid。
         */
        public String getOpenid() {
            return openid;
        }
        /**
         * 设置 openid。
         */
        public void setOpenid(String openid) {
            this.openid = openid;
        }
        /**
         * 获取 unionid。
         */
        public String getUnionid() {
            return unionid;
        }
        /**
         * 设置 unionid。
         */
        public void setUnionid(String unionid) {
            this.unionid = unionid;
        }
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppWechatUserProfile.java
New file
@@ -0,0 +1,20 @@
package com.tcm.training.admin.app.auth.service;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
 * 微信登录链路解析出的用户资料。
 */
@Getter
@Setter
@NoArgsConstructor
public class AppWechatUserProfile {
    private String openid;
    private String unionid;
    private String nickname;
    private String avatar;
    private Integer sex;
}
tcm-training-admin-backend/src/main/resources/application.yml
@@ -72,6 +72,15 @@
      debug-enabled: false
      # 开发环境调试验证码,默认留空,需在 application-local.yml 中显式开启
      debug-code: ""
    wechat:
      # 微信开放平台 AppId,当前阶段可先留空,补齐后启用服务端换票模式
      app-id: ""
      # 微信开放平台 AppSecret,当前阶段可先留空,补齐后启用服务端换票模式
      app-secret: ""
      # 微信用户信息接口地址
      user-info-url: https://api.weixin.qq.com/sns/userinfo
      # 微信授权换票接口地址
      access-token-url: https://api.weixin.qq.com/sns/oauth2/access_token
logging:
  level:
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java
@@ -2,11 +2,11 @@
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tcm.training.admin.app.auth.dto.AppPasswordLoginRequest;
import com.tcm.training.admin.app.auth.dto.AppPasswordResetRequest;
import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest;
import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest;
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse;
import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse;
@@ -31,10 +31,12 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -63,6 +65,9 @@
    private AppAuthSessionService appAuthSessionService;
    @Mock
    private AppWechatAuthClient appWechatAuthClient;
    @Mock
    private StringRedisTemplate stringRedisTemplate;
    @Mock
@@ -83,6 +88,7 @@
                appSmsCodeService,
                passwordEncoder,
                appAuthSessionService,
                appWechatAuthClient,
                stringRedisTemplate
        );
    }
@@ -213,6 +219,82 @@
    }
    /**
     * 微信已绑定用户登录时应更新微信资料并返回统一登录响应。
     */
    @Test
    void wechatLoginShouldReuseExistingUser() {
        AppWechatLoginRequest request = new AppWechatLoginRequest();
        request.setAccessToken("access-token");
        request.setOpenid("openid-1");
        AppWechatUserProfile profile = new AppWechatUserProfile();
        profile.setOpenid("openid-1");
        profile.setNickname("微信昵称");
        profile.setAvatar("https://cdn.example/avatar.png");
        profile.setSex(2);
        AppUser user = buildEnabledUser("13800138000", "ENCODED");
        user.setWechatOpenid("openid-1");
        user.setNickname(null);
        user.setAvatar(null);
        SaTokenInfo tokenInfo = new SaTokenInfo();
        tokenInfo.setTokenValue("wechat-token");
        tokenInfo.setTokenTimeout(7200L);
        when(appWechatAuthClient.resolveUserProfile(request)).thenReturn(profile);
        when(userMapper.selectOne(any())).thenReturn(user);
        when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
        AppLoginResponse response = appAuthService.wechatLogin(request);
        verify(userMapper).updateById(appUserCaptor.capture());
        verify(userMapper, never()).insert(any());
        assertEquals("openid-1", appUserCaptor.getValue().getWechatOpenid());
        assertEquals("微信昵称", appUserCaptor.getValue().getNickname());
        assertEquals("https://cdn.example/avatar.png", appUserCaptor.getValue().getAvatar());
        assertEquals(Integer.valueOf(0), appUserCaptor.getValue().getGender());
        assertEquals("微信昵称", response.getUser().getNickname());
        assertEquals("https://cdn.example/avatar.png", response.getUser().getAvatar());
    }
    /**
     * 微信首次登录时应自动创建普通用户并返回统一登录响应。
     */
    @Test
    void wechatLoginShouldCreateUserWhenOpenidNotExists() {
        AppWechatLoginRequest request = new AppWechatLoginRequest();
        request.setAccessToken("access-token");
        request.setOpenid("openid-new");
        AppWechatUserProfile profile = new AppWechatUserProfile();
        profile.setOpenid("openid-new");
        profile.setNickname("新微信用户");
        profile.setAvatar("https://cdn.example/new-avatar.png");
        profile.setSex(1);
        SaTokenInfo tokenInfo = new SaTokenInfo();
        tokenInfo.setTokenValue("new-token");
        tokenInfo.setTokenTimeout(7200L);
        when(appWechatAuthClient.resolveUserProfile(request)).thenReturn(profile);
        when(userMapper.selectOne(any())).thenReturn(null);
        when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
        AppLoginResponse response = appAuthService.wechatLogin(request);
        verify(userMapper).insert(appUserCaptor.capture());
        verify(userMapper, never()).updateById(any());
        assertNull(appUserCaptor.getValue().getPhone());
        assertEquals("openid-new", appUserCaptor.getValue().getWechatOpenid());
        assertEquals("新微信用户", appUserCaptor.getValue().getNickname());
        assertEquals("https://cdn.example/new-avatar.png", appUserCaptor.getValue().getAvatar());
        assertEquals(Integer.valueOf(1), appUserCaptor.getValue().getGender());
        assertEquals(Integer.valueOf(0), appUserCaptor.getValue().getType());
        assertEquals("new-token", response.getToken());
    }
    /**
     * 统一构造启用中的 APP 用户测试桩。
     */
    private AppUser buildEnabledUser(String phone, String password) {
traditionalApp/pages/login/login.vue
@@ -58,7 +58,7 @@
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { onUnmounted, ref } from 'vue'
import { passwordLogin, sendSmsCode, smsLogin } from './service.js'
import { passwordLogin, sendSmsCode, smsLogin, wechatLogin } from './service.js'
const loginType = ref<'code' | 'password'>('code')
const phone = ref('')
@@ -93,6 +93,27 @@
    msg?: string
    message?: string
    data: T
}
interface AppWechatLoginPayload {
    code?: string
    accessToken?: string
    openid?: string
    unionid?: string
}
interface WechatAuthResult {
    access_token?: string
    accessToken?: string
    openid?: string
    openId?: string
    unionid?: string
    unionId?: string
}
interface WechatLoginResult {
    code?: string
    authResult?: WechatAuthResult
}
/**
@@ -150,25 +171,42 @@
}
/**
 * 执行微信登录占位逻辑。
 * 执行微信登录,优先将微信授权结果交给后端拉取昵称和头像。
 */
const handleWechatLogin = () => {
const handleWechatLogin = async () => {
    if (loading.value) {
        return
    }
    // #ifdef APP-PLUS
    uni.login({
        provider: 'weixin',
        success: () => {
            uni.showToast({
                title: '微信登录暂未开放',
                icon: 'none'
            })
        },
        fail: () => {
            uni.showToast({
                title: '微信登录失败',
                icon: 'none'
            })
    loading.value = true
    try {
        const loginRes = await requestWechatAuthorization()
        const payload = buildWechatLoginPayload(loginRes)
        if (!payload) {
            return
        }
    })
        const res = await wechatLogin(payload)
        if (!isSuccessResponse(res)) {
            return
        }
        saveLoginSession(res.data)
        uni.showToast({
            title: '登录成功',
            icon: 'success'
        })
        setTimeout(() => {
            uni.switchTab({
                url: '/pages/home/home'
            })
        }, 300)
    } catch (error) {
        uni.showToast({
            title: '微信登录失败',
            icon: 'none'
        })
    } finally {
        loading.value = false
    }
    // #endif
    // #ifndef APP-PLUS
@@ -177,6 +215,47 @@
        icon: 'none'
    })
    // #endif
}
/**
 * 拉起微信授权,获取后端换取用户资料所需的授权结果。
 */
const requestWechatAuthorization = () => {
    return new Promise<WechatLoginResult>((resolve, reject) => {
        uni.login({
            provider: 'weixin',
            success: (res) => {
                resolve(res as WechatLoginResult)
            },
            fail: (error) => {
                reject(error)
            }
        })
    })
}
/**
 * 从 uni.login 返回结果中提取微信登录接口需要的参数。
 */
const buildWechatLoginPayload = (loginRes: WechatLoginResult) => {
    const authResult = loginRes?.authResult || {}
    const payload: AppWechatLoginPayload = {
        code: normalizeOptionalText(loginRes?.code),
        accessToken: normalizeOptionalText(authResult.access_token || authResult.accessToken),
        openid: normalizeOptionalText(authResult.openid || authResult.openId),
        unionid: normalizeOptionalText(authResult.unionid || authResult.unionId)
    }
    // 当前阶段优先兼容 APP 端直接返回的 accessToken/openid,后续补齐配置后也可走 code 模式。
    if ((payload.accessToken && payload.openid) || payload.code) {
        return payload
    }
    uni.showToast({
        title: '微信授权信息缺失',
        icon: 'none'
    })
    return null
}
/**
@@ -330,6 +409,16 @@
}
/**
 * 将可选字符串参数归一化为空串或有效文本。
 */
const normalizeOptionalText = (value: unknown) => {
    if (typeof value !== 'string') {
        return ''
    }
    return value.trim()
}
/**
 * 判断接口响应是否为成功态。
 */
const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => {
traditionalApp/pages/login/service.js
@@ -1,10 +1,10 @@
import request from '@/utils/request.js'
/**
 * 微信一键登录接口占位。
 * 使用微信授权结果完成 APP 登录。
 */
export const login = (data) => {
    return request.post('/openIdByJsCode', data)
export const wechatLogin = (data) => {
    return request.post('/api/app/auth/wechat-login', data)
}
/**