“mitao”
2026-04-30 4c2dd7fdd86ddfad69611a5e825ff4a0cfab3c06
fix:登录注册修改
1个文件已添加
12个文件已修改
528 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppAppleLoginRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/exception/InviteCodeRequiredException.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/common/GlobalExceptionHandler.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/bindPhone/bindPhone.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/help/help.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/login.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/mine.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/register/register.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppAppleLoginRequest.java
@@ -20,4 +20,6 @@
    private String authorizationCode;
    private String userIdentifier;
    private String inviteCode;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/exception/InviteCodeRequiredException.java
New file
@@ -0,0 +1,16 @@
package com.tcm.training.admin.app.auth.exception;
/**
 * 第三方授权首次建号缺少医生邀请码时抛出,供 APP 自定义弹窗引导。
 */
public class InviteCodeRequiredException extends RuntimeException {
    public static final String MESSAGE = "暂无邀请码,请联系管理员获取";
    /**
     * 构造缺少邀请码异常。
     */
    public InviteCodeRequiredException() {
        super(MESSAGE);
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java
@@ -12,6 +12,7 @@
import com.tcm.training.admin.app.auth.vo.AppLoginUserVO;
import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse;
import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse;
import com.tcm.training.admin.app.auth.exception.InviteCodeRequiredException;
import com.tcm.training.admin.app.share.service.AppShareService;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
@@ -114,6 +115,7 @@
        AppWechatUserProfile profile = appWechatAuthClient.resolveUserProfile(request);
        AppUser user = findUserByWechatOpenid(profile.getOpenid());
        if (user == null) {
            requireInviteCodeForThirdPartyRegister(request.getInviteCode());
            user = buildWechatUser(profile);
            bindInviteRelationIfAbsent(user, request.getInviteCode());
            userMapper.insert(user);
@@ -134,7 +136,9 @@
        AppAppleUserProfile profile = appAppleAuthClient.resolveUserProfile(request);
        AppUser user = findUserByAppleSubject(profile.getSubject());
        if (user == null) {
            requireInviteCodeForThirdPartyRegister(request.getInviteCode());
            user = buildAppleUser(profile);
            bindInviteRelationIfAbsent(user, request.getInviteCode());
            userMapper.insert(user);
            return createLoginResponse(user);
        }
@@ -276,6 +280,15 @@
    }
    /**
     * 第三方授权首次创建账号时必须携带医生邀请码。
     */
    private void requireInviteCodeForThirdPartyRegister(String inviteCode) {
        if (normalizeInviteCode(inviteCode) == null) {
            throw new InviteCodeRequiredException();
        }
    }
    /**
     * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。
     */
    private void bindInviteRelationIfAbsent(AppUser user, String inviteCode) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java
@@ -1,6 +1,7 @@
package com.tcm.training.admin.app.settings.controller;
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
import com.tcm.training.admin.app.settings.dto.AppSettingsNotificationUpdateRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPasswordUpdateRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
@@ -89,7 +90,7 @@
     * 首次绑定当前登录用户手机号。
     */
    @PutMapping("/phone/bind")
    public ApiResponse<AppSettingsProfileVO> bindPhone(@Valid @RequestBody AppSettingsPhoneBindRequest request) {
    public ApiResponse<AppLoginResponse> bindPhone(@Valid @RequestBody AppSettingsPhoneBindRequest request) {
        return ApiResponse.ok(appSettingsService.bindPhone(request));
    }
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
@@ -1,8 +1,11 @@
package com.tcm.training.admin.app.settings.service;
import cn.dev33.satoken.stp.SaTokenInfo;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
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.service.AppSmsCodeService;
import com.tcm.training.admin.app.auth.service.AppWechatAuthClient;
import com.tcm.training.admin.app.auth.service.AppWechatUserProfile;
@@ -120,7 +123,11 @@
        if (newPhone.equals(trimToEmpty(currentUser.getPhone()))) {
            throw new IllegalArgumentException("新手机号不能与当前手机号一致");
        }
        requirePhoneAvailable(newPhone, currentUser.getId());
        if (trimToNull(currentUser.getPhone()) == null) {
            validateThirdPartyPhoneBindable(currentUser, newPhone);
        } else {
            requirePhoneAvailable(newPhone, currentUser.getId());
        }
        appSmsCodeService.createPhoneChangeCode(newPhone);
    }
@@ -151,19 +158,20 @@
     * 首次绑定当前登录用户手机号,供微信登录未绑定手机号场景使用。
     */
    @Transactional
    public AppSettingsProfileVO bindPhone(AppSettingsPhoneBindRequest request) {
    public AppLoginResponse bindPhone(AppSettingsPhoneBindRequest request) {
        AppUser user = requireCurrentEnabledUser();
        String newPhone = normalizePhone(request.getNewPhone());
        String code = normalizeRequired(request.getCode(), "请输入验证码");
        if (trimToNull(user.getPhone()) != null) {
            throw new IllegalArgumentException("当前账号已绑定手机号");
        }
        requirePhoneAvailable(newPhone, user.getId());
        AppUser occupiedUser = findUserByPhone(newPhone);
        validatePhoneBindTarget(user, occupiedUser);
        appSmsCodeService.validatePhoneChangeCode(newPhone, code);
        user.setPhone(newPhone);
        bindInviteRelationIfAbsent(user, request.getInviteCode());
        userMapper.updateById(user);
        return toProfileVO(user);
        AppUser loginUser = occupiedUser == null
                ? bindPhoneToCurrentUser(user, newPhone, request.getInviteCode())
                : mergeThirdPartyUserToPhoneUser(user, occupiedUser, request.getInviteCode());
        return createLoginResponse(loginUser);
    }
    /**
@@ -180,6 +188,90 @@
        AppShareService.InviteBindingTarget bindingTarget = appShareService.resolveInviteBindingTarget(normalizedInviteCode);
        user.setClinicId(bindingTarget.getClinicId());
        user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId());
    }
    /**
     * 将手机号直接绑定到当前临时账号。
     */
    private AppUser bindPhoneToCurrentUser(AppUser user, String phone, String inviteCode) {
        user.setPhone(phone);
        bindInviteRelationIfAbsent(user, inviteCode);
        userMapper.updateById(user);
        return user;
    }
    /**
     * 将当前第三方临时账号合并到已存在的手机号账号。
     */
    private AppUser mergeThirdPartyUserToPhoneUser(AppUser currentUser, AppUser targetUser, String inviteCode) {
        requireEnabledUser(targetUser);
        copyThirdPartyBinding(currentUser, targetUser);
        fillMissingProfile(targetUser, currentUser);
        bindInviteRelationIfAbsent(targetUser, inviteCode);
        userMapper.updateById(targetUser);
        disableMergedTemporaryUser(currentUser);
        return targetUser;
    }
    /**
     * 校验首次绑定手机号时目标手机号是否允许承接当前第三方身份。
     */
    private void validateThirdPartyPhoneBindable(AppUser currentUser, String phone) {
        validatePhoneBindTarget(currentUser, findUserByPhone(phone));
    }
    /**
     * 校验目标手机号账号未绑定当前第三方类型。
     */
    private void validatePhoneBindTarget(AppUser currentUser, AppUser targetUser) {
        if (targetUser == null || targetUser.getId().equals(currentUser.getId())) {
            return;
        }
        if (hasWechatBinding(currentUser) || hasAppleBinding(currentUser)) {
            return;
        }
        throw new IllegalArgumentException("该手机号已注册");
    }
    /**
     * 将当前临时账号上的第三方票据写入目标手机号账号。
     */
    private void copyThirdPartyBinding(AppUser sourceUser, AppUser targetUser) {
        if (hasWechatBinding(sourceUser)) {
            targetUser.setWechatOpenid(sourceUser.getWechatOpenid());
            targetUser.setWechatNickname(sourceUser.getWechatNickname());
            targetUser.setWechatAvatar(sourceUser.getWechatAvatar());
            targetUser.setWechatBoundAt(sourceUser.getWechatBoundAt() == null ? LocalDateTime.now() : sourceUser.getWechatBoundAt());
        }
        if (hasAppleBinding(sourceUser)) {
            targetUser.setAppleUserIdentifier(sourceUser.getAppleUserIdentifier());
            targetUser.setAppleIdentitySubject(sourceUser.getAppleIdentitySubject());
            targetUser.setAppleBoundAt(sourceUser.getAppleBoundAt() == null ? LocalDateTime.now() : sourceUser.getAppleBoundAt());
        }
    }
    /**
     * 目标账号资料为空时,复用临时第三方账号资料补齐展示信息。
     */
    private void fillMissingProfile(AppUser targetUser, AppUser sourceUser) {
        if (trimToNull(targetUser.getNickname()) == null) {
            targetUser.setNickname(resolveNickname(sourceUser));
        }
        if (trimToNull(targetUser.getAvatar()) == null) {
            targetUser.setAvatar(resolveAvatar(sourceUser));
        }
        if (targetUser.getGender() == null) {
            targetUser.setGender(sourceUser.getGender());
        }
    }
    /**
     * 合并后软删除第三方临时账号,避免产生重复用户。
     */
    private void disableMergedTemporaryUser(AppUser currentUser) {
        currentUser.setStatus(0);
        currentUser.setDeleted(1);
        userMapper.updateById(currentUser);
    }
    /**
@@ -353,6 +445,69 @@
    }
    /**
     * 按手机号查询未删除的 APP 用户。
     */
    private AppUser findUserByPhone(String phone) {
        return userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getPhone, phone)
                .eq(AppUser::getDeleted, 0)
                .last("limit 1"));
    }
    /**
     * 校验用户启用状态。
     */
    private void requireEnabledUser(AppUser user) {
        if (user.getStatus() == null || user.getStatus().intValue() != 1) {
            throw new IllegalArgumentException("账号已被禁用");
        }
    }
    /**
     * 判断账号是否已绑定微信。
     */
    private boolean hasWechatBinding(AppUser user) {
        return trimToNull(user.getWechatOpenid()) != null;
    }
    /**
     * 判断账号是否已绑定苹果。
     */
    private boolean hasAppleBinding(AppUser user) {
        return trimToNull(user.getAppleIdentitySubject()) != null;
    }
    /**
     * 为绑定后的用户签发新的 APP 登录态。
     */
    private AppLoginResponse createLoginResponse(AppUser user) {
        appAuthSessionService.login(user.getId());
        SaTokenInfo tokenInfo = appAuthSessionService.getTokenInfo();
        return new AppLoginResponse(
                tokenInfo.getTokenValue(),
                "Bearer",
                tokenInfo.getTokenTimeout(),
                buildLoginUser(user)
        );
    }
    /**
     * 将用户实体转换为登录响应中的用户摘要。
     */
    private AppLoginUserVO buildLoginUser(AppUser user) {
        AppLoginUserVO loginUser = new AppLoginUserVO();
        loginUser.setId(user.getId());
        loginUser.setNickname(resolveNickname(user));
        loginUser.setPhone(trimToEmpty(user.getPhone()));
        loginUser.setAvatar(resolveAvatar(user));
        loginUser.setType(user.getType());
        loginUser.setStatus(user.getStatus());
        loginUser.setClinicId(user.getClinicId());
        loginUser.setMemberExpireAt(user.getMemberExpireAt());
        return loginUser;
    }
    /**
     * 校验手机号未被其他有效用户占用。
     */
    private void requirePhoneAvailable(String phone, Long currentUserId) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/common/GlobalExceptionHandler.java
@@ -2,6 +2,7 @@
import cn.dev33.satoken.exception.NotLoginException;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.tcm.training.admin.app.auth.exception.InviteCodeRequiredException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.validation.BindException;
@@ -47,6 +48,12 @@
        return ApiResponse.fail(400, "参数错误");
    }
    @ExceptionHandler(InviteCodeRequiredException.class)
    public ApiResponse<Void> handleInviteCodeRequired(InviteCodeRequiredException e, HttpServletRequest request) {
        log.warn("第三方授权缺少邀请码 [{} {}]: {}", request.getMethod(), request.getRequestURI(), e.getMessage());
        return ApiResponse.fail(506, e.getMessage());
    }
    @ExceptionHandler(IllegalArgumentException.class)
    public ApiResponse<Void> handleIllegalArgument(IllegalArgumentException e, HttpServletRequest request) {
        log.warn("业务参数异常 [{} {}]: {}", request.getMethod(), request.getRequestURI(), e.getMessage(), e);
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java
@@ -11,6 +11,7 @@
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;
import com.tcm.training.admin.app.auth.exception.InviteCodeRequiredException;
import com.tcm.training.admin.app.share.service.AppShareService;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
@@ -276,6 +277,7 @@
        AppWechatLoginRequest request = new AppWechatLoginRequest();
        request.setAccessToken("access-token");
        request.setOpenid("openid-new");
        request.setInviteCode("ABC123");
        AppWechatUserProfile profile = new AppWechatUserProfile();
        profile.setOpenid("openid-new");
@@ -289,6 +291,7 @@
        when(appWechatAuthClient.resolveUserProfile(request)).thenReturn(profile);
        when(userMapper.selectOne(any())).thenReturn(null);
        when(appShareService.resolveInviteBindingTarget("ABC123")).thenReturn(buildInviteTarget());
        when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
        AppLoginResponse response = appAuthService.wechatLogin(request);
@@ -301,7 +304,31 @@
        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(Long.valueOf(10L), appUserCaptor.getValue().getClinicId());
        assertEquals(Long.valueOf(20L), appUserCaptor.getValue().getSuperiorClinicUserId());
        assertEquals("new-token", response.getToken());
    }
    /**
     * 微信首次登录未携带邀请码时应提示联系管理员获取。
     */
    @Test
    void wechatLoginShouldRequireInviteCodeWhenOpenidNotExists() {
        AppWechatLoginRequest request = new AppWechatLoginRequest();
        request.setAccessToken("access-token");
        request.setOpenid("openid-new");
        AppWechatUserProfile profile = new AppWechatUserProfile();
        profile.setOpenid("openid-new");
        when(appWechatAuthClient.resolveUserProfile(request)).thenReturn(profile);
        when(userMapper.selectOne(any())).thenReturn(null);
        InviteCodeRequiredException exception = assertThrows(InviteCodeRequiredException.class,
                () -> appAuthService.wechatLogin(request));
        assertEquals("暂无邀请码,请联系管理员获取", exception.getMessage());
        verify(userMapper, never()).insert(any());
    }
    /**
@@ -348,6 +375,7 @@
        AppAppleLoginRequest request = new AppAppleLoginRequest();
        request.setIdentityToken("identity-token");
        request.setUserIdentifier("apple-user-new");
        request.setInviteCode("ABC123");
        AppAppleUserProfile profile = new AppAppleUserProfile();
        profile.setSubject("apple-subject-new");
@@ -359,6 +387,7 @@
        when(appAppleAuthClient.resolveUserProfile(request)).thenReturn(profile);
        when(userMapper.selectOne(any())).thenReturn(null);
        when(appShareService.resolveInviteBindingTarget("ABC123")).thenReturn(buildInviteTarget());
        when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
        AppLoginResponse response = appAuthService.appleLogin(request);
@@ -370,7 +399,32 @@
        assertEquals("apple-user-new", appUserCaptor.getValue().getAppleUserIdentifier());
        assertEquals("苹果用户", appUserCaptor.getValue().getNickname());
        assertEquals(Integer.valueOf(0), appUserCaptor.getValue().getType());
        assertEquals(Long.valueOf(10L), appUserCaptor.getValue().getClinicId());
        assertEquals(Long.valueOf(20L), appUserCaptor.getValue().getSuperiorClinicUserId());
        assertEquals("apple-new-token", response.getToken());
    }
    /**
     * 苹果首次登录未携带邀请码时应提示联系管理员获取。
     */
    @Test
    void appleLoginShouldRequireInviteCodeWhenSubjectNotExists() {
        AppAppleLoginRequest request = new AppAppleLoginRequest();
        request.setIdentityToken("identity-token");
        request.setUserIdentifier("apple-user-new");
        AppAppleUserProfile profile = new AppAppleUserProfile();
        profile.setSubject("apple-subject-new");
        profile.setUserIdentifier("apple-user-new");
        when(appAppleAuthClient.resolveUserProfile(request)).thenReturn(profile);
        when(userMapper.selectOne(any())).thenReturn(null);
        InviteCodeRequiredException exception = assertThrows(InviteCodeRequiredException.class,
                () -> appAuthService.appleLogin(request));
        assertEquals("暂无邀请码,请联系管理员获取", exception.getMessage());
        verify(userMapper, never()).insert(any());
    }
    /**
@@ -391,6 +445,17 @@
    }
    /**
     * 构造邀请绑定目标。
     */
    private AppShareService.InviteBindingTarget buildInviteTarget() {
        AppShareService.InviteBindingTarget target = new AppShareService.InviteBindingTarget();
        target.setInviteCode("ABC123");
        target.setClinicId(10L);
        target.setSuperiorClinicUserId(20L);
        return target;
    }
    /**
     * 统一构造启用中的 APP 用户测试桩。
     */
    private AppUser buildEnabledUser(String phone, String password) {
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
@@ -2,12 +2,13 @@
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import cn.dev33.satoken.stp.SaTokenInfo;
import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
import com.tcm.training.admin.app.auth.service.AppSmsCodeService;
import com.tcm.training.admin.app.auth.service.AppWechatAuthClient;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
import com.tcm.training.admin.app.settings.mapper.AppUserNotificationSettingMapper;
import com.tcm.training.admin.app.settings.vo.AppSettingsProfileVO;
import com.tcm.training.admin.app.share.service.AppShareService;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
@@ -28,6 +29,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -103,12 +105,15 @@
        when(userMapper.selectById(1L)).thenReturn(currentUser);
        when(userMapper.selectOne(any())).thenReturn(null);
        doNothing().when(appSmsCodeService).validatePhoneChangeCode("13800138000", "123456");
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildToken("bind-token"));
        AppSettingsProfileVO response = appSettingsService.bindPhone(request);
        AppLoginResponse response = appSettingsService.bindPhone(request);
        verify(userMapper).updateById(appUserCaptor.capture());
        verify(appAuthSessionService).login(1L);
        assertEquals("13800138000", appUserCaptor.getValue().getPhone());
        assertEquals("13800138000", response.getPhone());
        assertEquals("13800138000", response.getUser().getPhone());
        assertEquals("bind-token", response.getToken());
    }
    /**
@@ -132,10 +137,141 @@
    }
    /**
     * 目标手机号被其他有效用户占用时不允许绑定。
     * 微信临时账号绑定已注册但未绑定微信的手机号时,应合并到手机号账号。
     */
    @Test
    void bindPhoneShouldRejectOccupiedPhone() {
    void bindPhoneShouldMergeWechatUserIntoExistingPhoneUser() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("123456");
        AppUser currentUser = buildEnabledUser(null);
        currentUser.setWechatOpenid("wx-openid");
        currentUser.setNickname(null);
        currentUser.setWechatNickname("微信昵称");
        AppUser occupiedUser = buildEnabledUser("13800138000");
        occupiedUser.setId(2L);
        occupiedUser.setNickname(null);
        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
        when(userMapper.selectById(1L)).thenReturn(currentUser);
        when(userMapper.selectOne(any())).thenReturn(occupiedUser);
        doNothing().when(appSmsCodeService).validatePhoneChangeCode("13800138000", "123456");
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildToken("merged-token"));
        AppLoginResponse response = appSettingsService.bindPhone(request);
        verify(userMapper, times(2)).updateById(appUserCaptor.capture());
        AppUser mergedUser = appUserCaptor.getAllValues().get(0);
        AppUser deletedTemporaryUser = appUserCaptor.getAllValues().get(1);
        verify(appAuthSessionService).login(2L);
        assertEquals("wx-openid", mergedUser.getWechatOpenid());
        assertEquals("微信昵称", mergedUser.getNickname());
        assertEquals(Integer.valueOf(1), deletedTemporaryUser.getDeleted());
        assertEquals(Long.valueOf(2L), response.getUser().getId());
        assertEquals("merged-token", response.getToken());
    }
    /**
     * 微信临时账号绑定已绑定微信的手机号时,应直接覆盖目标账号微信票据。
     */
    @Test
    void bindPhoneShouldOverwriteWechatBindingWhenPhoneUserAlreadyBoundWechat() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("123456");
        AppUser currentUser = buildEnabledUser(null);
        currentUser.setWechatOpenid("wx-openid");
        currentUser.setWechatNickname("新微信昵称");
        AppUser occupiedUser = buildEnabledUser("13800138000");
        occupiedUser.setId(2L);
        occupiedUser.setWechatOpenid("other-wx-openid");
        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
        when(userMapper.selectById(1L)).thenReturn(currentUser);
        when(userMapper.selectOne(any())).thenReturn(occupiedUser);
        doNothing().when(appSmsCodeService).validatePhoneChangeCode("13800138000", "123456");
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildToken("wechat-overwrite-token"));
        AppLoginResponse response = appSettingsService.bindPhone(request);
        verify(userMapper, times(2)).updateById(appUserCaptor.capture());
        AppUser mergedUser = appUserCaptor.getAllValues().get(0);
        AppUser deletedTemporaryUser = appUserCaptor.getAllValues().get(1);
        verify(appAuthSessionService).login(2L);
        assertEquals("wx-openid", mergedUser.getWechatOpenid());
        assertEquals("新微信昵称", mergedUser.getWechatNickname());
        assertEquals(Integer.valueOf(1), deletedTemporaryUser.getDeleted());
        assertEquals(Long.valueOf(2L), response.getUser().getId());
    }
    /**
     * 苹果临时账号绑定已注册但未绑定苹果的手机号时,应合并到手机号账号。
     */
    @Test
    void bindPhoneShouldMergeAppleUserIntoExistingPhoneUser() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("123456");
        AppUser currentUser = buildEnabledUser(null);
        currentUser.setAppleIdentitySubject("apple-subject");
        currentUser.setAppleUserIdentifier("apple-user");
        AppUser occupiedUser = buildEnabledUser("13800138000");
        occupiedUser.setId(2L);
        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
        when(userMapper.selectById(1L)).thenReturn(currentUser);
        when(userMapper.selectOne(any())).thenReturn(occupiedUser);
        doNothing().when(appSmsCodeService).validatePhoneChangeCode("13800138000", "123456");
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildToken("apple-merged-token"));
        AppLoginResponse response = appSettingsService.bindPhone(request);
        verify(userMapper, times(2)).updateById(appUserCaptor.capture());
        AppUser mergedUser = appUserCaptor.getAllValues().get(0);
        verify(appAuthSessionService).login(2L);
        assertEquals("apple-subject", mergedUser.getAppleIdentitySubject());
        assertEquals("apple-user", mergedUser.getAppleUserIdentifier());
        assertEquals(Long.valueOf(2L), response.getUser().getId());
    }
    /**
     * 苹果临时账号绑定已绑定苹果的手机号时,应直接覆盖目标账号苹果票据。
     */
    @Test
    void bindPhoneShouldOverwriteAppleBindingWhenPhoneUserAlreadyBoundApple() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("123456");
        AppUser currentUser = buildEnabledUser(null);
        currentUser.setAppleIdentitySubject("apple-subject");
        currentUser.setAppleUserIdentifier("apple-user");
        AppUser occupiedUser = buildEnabledUser("13800138000");
        occupiedUser.setId(2L);
        occupiedUser.setAppleIdentitySubject("other-apple-subject");
        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
        when(userMapper.selectById(1L)).thenReturn(currentUser);
        when(userMapper.selectOne(any())).thenReturn(occupiedUser);
        doNothing().when(appSmsCodeService).validatePhoneChangeCode("13800138000", "123456");
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildToken("apple-overwrite-token"));
        AppLoginResponse response = appSettingsService.bindPhone(request);
        verify(userMapper, times(2)).updateById(appUserCaptor.capture());
        AppUser mergedUser = appUserCaptor.getAllValues().get(0);
        AppUser deletedTemporaryUser = appUserCaptor.getAllValues().get(1);
        verify(appAuthSessionService).login(2L);
        assertEquals("apple-subject", mergedUser.getAppleIdentitySubject());
        assertEquals("apple-user", mergedUser.getAppleUserIdentifier());
        assertEquals(Integer.valueOf(1), deletedTemporaryUser.getDeleted());
        assertEquals(Long.valueOf(2L), response.getUser().getId());
    }
    /**
     * 非第三方临时账号绑定已注册手机号时仍按占用处理。
     */
    @Test
    void bindPhoneShouldRejectOccupiedPhoneWithoutThirdPartyBinding() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("123456");
@@ -177,6 +313,16 @@
    }
    /**
     * 构造测试登录态。
     */
    private SaTokenInfo buildToken(String tokenValue) {
        SaTokenInfo tokenInfo = new SaTokenInfo();
        tokenInfo.setTokenValue(tokenValue);
        tokenInfo.setTokenTimeout(7200L);
        return tokenInfo;
    }
    /**
     * 构造启用状态 APP 用户。
     */
    private AppUser buildEnabledUser(String phone) {
traditionalApp/pages/bindPhone/bindPhone.vue
@@ -54,12 +54,22 @@
    data: T
}
interface SettingsProfile {
interface AppLoginUser {
    id?: number | null
    nickname?: string
    avatar?: string
    phone?: string
    type?: number | null
    status?: number | null
    clinicId?: number | null
    memberExpireAt?: string
}
interface AppLoginPayload {
    token?: string
    tokenType?: string
    expiresIn?: number
    user?: AppLoginUser
}
const phone = ref('')
@@ -173,7 +183,7 @@
        if (!isSuccessResponse(response)) {
            return
        }
        cacheBoundPhone(response.data, normalizedPhone)
        saveLoginSession(response.data, normalizedPhone)
        clearCachedInviteCode()
        uni.showToast({
            title: '绑定成功',
@@ -220,16 +230,20 @@
}
/**
 * 绑定成功后同步本地用户缓存,保证个人中心立即展示手机号。
 * 绑定成功后保存后端返回的新登录态,确保后续请求切到正式账号。
 */
const cacheBoundPhone = (profile: SettingsProfile | undefined, fallbackPhone: string) => {
    const cachedUser = uni.getStorageSync('appUserInfo')
    const nextUserInfo = {
        ...(cachedUser && typeof cachedUser === 'object' ? cachedUser : {}),
        ...(profile && typeof profile === 'object' ? profile : {}),
        phone: normalizeOptionalText(profile?.phone) || fallbackPhone
const saveLoginSession = (loginData: AppLoginPayload | undefined, fallbackPhone: string) => {
    const userInfo = loginData?.user || {}
    const tokenValue = normalizeOptionalText(loginData?.token)
    if (tokenValue) {
        const authToken = `${loginData?.tokenType || 'Bearer'} ${tokenValue}`.trim()
        uni.setStorageSync('token', authToken)
    }
    uni.setStorageSync('appUserInfo', nextUserInfo)
    uni.setStorageSync('appUserInfo', {
        ...userInfo,
        phone: normalizeOptionalText(userInfo.phone) || fallbackPhone
    })
    uni.setStorageSync('showLogoutModal', 1)
}
/**
traditionalApp/pages/help/help.vue
@@ -13,12 +13,14 @@
import { getHelpQuestionDetail } from './service'
const questionId = ref<number | null>(null)
const helpType = ref('')
const htmlContent = ref('')
/**
 * 页面加载时读取问题 ID。
 */
onLoad((options) => {
    helpType.value = normalizeOptionalText(options?.type)
    questionId.value = normalizeOptionalNumber(options?.id)
})
@@ -33,6 +35,13 @@
 * 拉取常见问题详情并设置页面标题。
 */
async function loadQuestionDetail() {
    if (helpType.value === 'invite') {
        uni.setNavigationBarTitle({
            title: '如何获取邀请码',
        })
        htmlContent.value = buildInviteHelpContent()
        return
    }
    if (!questionId.value) {
        htmlContent.value = ''
        uni.setNavigationBarTitle({
@@ -56,6 +65,20 @@
}
/**
 * 构建邀请码获取说明内容。
 */
function buildInviteHelpContent() {
    return `
        <div style="padding: 0 32rpx; color: #333; line-height: 1.8; font-size: 30rpx;">
            <h2 style="font-size: 40rpx; margin: 0 0 24rpx; color: #1f1f1f;">如何获取邀请码?</h2>
            <p>邀请码由合作诊所或医生提供,用于确认您的邀请关系。</p>
            <p>请联系您的诊所管理员、邀请医生或平台客服获取正确的邀请码后,再返回登录或注册。</p>
            <p>通过医生分享二维码下载安装 APP 时,系统会自动带入邀请码;若未自动获取,请重新扫码或联系管理员确认。</p>
        </div>
    `
}
/**
 * 将可选文本归一化为空字符串。
 */
function normalizeOptionalText(value: unknown) {
traditionalApp/pages/login/login.vue
@@ -112,6 +112,7 @@
        identityToken : string
        authorizationCode ?: string
        userIdentifier ?: string
        inviteCode ?: string
    }
    interface WechatAuthResult {
@@ -242,6 +243,10 @@
                return
            }
            const res = await wechatLogin(payload)
            if (shouldShowInviteRequiredGuide(res)) {
                showInviteRequiredGuide()
                return
            }
            if (!isSuccessResponse(res)) {
                return
            }
@@ -302,6 +307,10 @@
                return
            }
            const res = await appleLogin(payload)
            if (shouldShowInviteRequiredGuide(res)) {
                showInviteRequiredGuide()
                return
            }
            if (!isSuccessResponse(res)) {
                return
            }
@@ -542,7 +551,8 @@
        const payload : AppAppleLoginPayload = {
            identityToken,
            authorizationCode: normalizeOptionalText(authResult.authorizationCode),
            userIdentifier: normalizeOptionalText(authResult.userIdentifier || authResult.user)
            userIdentifier: normalizeOptionalText(authResult.userIdentifier || authResult.user),
            inviteCode: getCachedInviteCode()
        }
        return payload
    }
@@ -736,6 +746,30 @@
    }
    /**
     * 判断是否需要展示缺少邀请码引导弹窗。
     */
    const shouldShowInviteRequiredGuide = (response : ApiResponse<unknown> | undefined) => {
        return !!response && response.code === 506 && normalizeOptionalText(response.msg || response.message) === '暂无邀请码,请联系管理员获取'
    }
    /**
     * 展示缺少邀请码弹窗,关闭后跳转到邀请码说明详情。
     */
    const showInviteRequiredGuide = () => {
        uni.showModal({
            title: '提示',
            content: '暂无邀请码,请联系管理员获取',
            showCancel: false,
            confirmText: '我知道了',
            success: () => {
                uni.navigateTo({
                    url: '/pages/help/help?type=invite'
                })
            }
        })
    }
    /**
     * 判断接口响应是否为成功态。
     */
    const isSuccessResponse = (response : ApiResponse<unknown> | undefined) => {
traditionalApp/pages/mine/mine.vue
@@ -44,7 +44,7 @@
                    <u-icon name="arrow-right" color="#999" size="16"></u-icon>
                </view>
            </view>
            <view class="icp-text">ICP备案号:XXXXXXXXXXXXXXXX</view>
            <view class="icp-text">ICP备案号:琼ICP备2025059220号-3A</view>
        </view>
    </view>
</template>
traditionalApp/pages/register/register.vue
@@ -371,7 +371,7 @@
 */
const showHelp = () => {
    uni.navigateTo({
        url: '/pages/help/help'
        url: '/pages/help/help?type=invite'
    })
}