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' }) }