tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppWechatLoginRequest.java
@@ -16,4 +16,6 @@ private String accessToken; private String openid; private String unionid; private String inviteCode; } 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.share.service.AppShareService; import com.tcm.training.admin.member.entity.AppUser; import com.tcm.training.admin.member.mapper.UserMapper; import cn.dev33.satoken.stp.SaTokenInfo; @@ -41,6 +42,7 @@ private final AppAuthSessionService appAuthSessionService; private final AppWechatAuthClient appWechatAuthClient; private final AppAppleAuthClient appAppleAuthClient; private final AppShareService appShareService; private final StringRedisTemplate stringRedisTemplate; /** @@ -52,6 +54,7 @@ AppAuthSessionService appAuthSessionService, AppWechatAuthClient appWechatAuthClient, AppAppleAuthClient appAppleAuthClient, AppShareService appShareService, StringRedisTemplate stringRedisTemplate) { this.userMapper = userMapper; this.appSmsCodeService = appSmsCodeService; @@ -59,6 +62,7 @@ this.appAuthSessionService = appAuthSessionService; this.appWechatAuthClient = appWechatAuthClient; this.appAppleAuthClient = appAppleAuthClient; this.appShareService = appShareService; this.stringRedisTemplate = stringRedisTemplate; } @@ -111,6 +115,7 @@ AppUser user = findUserByWechatOpenid(profile.getOpenid()); if (user == null) { user = buildWechatUser(profile); bindInviteRelationIfAbsent(user, request.getInviteCode()); userMapper.insert(user); return createLoginResponse(user); } @@ -271,6 +276,22 @@ } /** * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。 */ private void bindInviteRelationIfAbsent(AppUser user, String inviteCode) { if (user.getClinicId() != null || user.getSuperiorClinicUserId() != null) { return; } String normalizedInviteCode = normalizeInviteCode(inviteCode); if (normalizedInviteCode == null) { return; } AppShareService.InviteBindingTarget bindingTarget = appShareService.resolveInviteBindingTarget(normalizedInviteCode); user.setClinicId(bindingTarget.getClinicId()); user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId()); } /** * 基于苹果资料构造首次登录时的本地普通用户。 */ private AppUser buildAppleUser(AppAppleUserProfile profile) { @@ -367,6 +388,18 @@ } /** * 归一化医生邀请码,非法时忽略以保持第三方登录主流程可用。 */ private String normalizeInviteCode(String inviteCode) { String normalized = trimToNull(inviteCode); if (normalized == null) { return null; } String upperInviteCode = normalized.toUpperCase(); return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null; } /** * 归一化并校验忘记密码场景下的新密码格式。 */ private String normalizeNewPassword(String password) { tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java
@@ -19,4 +19,6 @@ @NotBlank(message = "请输入验证码") private String code; private String inviteCode; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
@@ -17,6 +17,7 @@ import com.tcm.training.admin.app.settings.vo.AppSettingsNotificationVO; import com.tcm.training.admin.app.settings.vo.AppSettingsProfileVO; import com.tcm.training.admin.app.settings.vo.AppSettingsWechatBindingVO; 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; import com.tcm.training.admin.upload.ObsUploadService; @@ -54,6 +55,7 @@ private final AppSmsCodeService appSmsCodeService; private final BCryptPasswordEncoder passwordEncoder; private final AppWechatAuthClient appWechatAuthClient; private final AppShareService appShareService; private final AppUserNotificationSettingMapper appUserNotificationSettingMapper; private final ObsUploadService obsUploadService; @@ -65,6 +67,7 @@ AppSmsCodeService appSmsCodeService, BCryptPasswordEncoder passwordEncoder, AppWechatAuthClient appWechatAuthClient, AppShareService appShareService, AppUserNotificationSettingMapper appUserNotificationSettingMapper, ObsUploadService obsUploadService) { this.appAuthSessionService = appAuthSessionService; @@ -72,6 +75,7 @@ this.appSmsCodeService = appSmsCodeService; this.passwordEncoder = passwordEncoder; this.appWechatAuthClient = appWechatAuthClient; this.appShareService = appShareService; this.appUserNotificationSettingMapper = appUserNotificationSettingMapper; this.obsUploadService = obsUploadService; } @@ -157,8 +161,25 @@ requirePhoneAvailable(newPhone, user.getId()); appSmsCodeService.validatePhoneChangeCode(newPhone, code); user.setPhone(newPhone); bindInviteRelationIfAbsent(user, request.getInviteCode()); userMapper.updateById(user); return toProfileVO(user); } /** * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。 */ private void bindInviteRelationIfAbsent(AppUser user, String inviteCode) { if (user.getClinicId() != null || user.getSuperiorClinicUserId() != null) { return; } String normalizedInviteCode = normalizeInviteCode(inviteCode); if (normalizedInviteCode == null) { return; } AppShareService.InviteBindingTarget bindingTarget = appShareService.resolveInviteBindingTarget(normalizedInviteCode); user.setClinicId(bindingTarget.getClinicId()); user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId()); } /** @@ -422,6 +443,18 @@ } /** * 归一化医生邀请码,非法时忽略以保持绑定手机号主流程可用。 */ private String normalizeInviteCode(String inviteCode) { String normalized = trimToNull(inviteCode); if (normalized == null) { return null; } String upperInviteCode = normalized.toUpperCase(); return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null; } /** * 校验通知时间仅允许使用页面固定选项。 */ private Integer normalizeAdvanceMinutes(Integer advanceMinutes) { 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.share.service.AppShareService; import com.tcm.training.admin.member.entity.AppUser; import com.tcm.training.admin.member.mapper.UserMapper; import cn.dev33.satoken.stp.SaTokenInfo; @@ -72,6 +73,9 @@ private AppAppleAuthClient appAppleAuthClient; @Mock private AppShareService appShareService; @Mock private StringRedisTemplate stringRedisTemplate; @Mock @@ -94,6 +98,7 @@ appAuthSessionService, appWechatAuthClient, appAppleAuthClient, appShareService, stringRedisTemplate ); } tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
@@ -8,6 +8,7 @@ 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; import com.tcm.training.admin.upload.ObsUploadService; @@ -58,6 +59,9 @@ private AppWechatAuthClient appWechatAuthClient; @Mock private AppShareService appShareService; @Mock private AppUserNotificationSettingMapper appUserNotificationSettingMapper; @Mock @@ -79,6 +83,7 @@ appSmsCodeService, passwordEncoder, appWechatAuthClient, appShareService, appUserNotificationSettingMapper, obsUploadService ); traditionalApp/pages/bindPhone/bindPhone.vue
@@ -45,6 +45,7 @@ <script setup lang="ts"> import { ref } from 'vue' import { bindSettingsPhone, sendBindPhoneCode } from './service.js' import { clearCachedInviteCode, getCachedInviteCode } from '@/utils/invite-cache.js' interface ApiResponse<T> { code: number @@ -166,12 +167,14 @@ try { const response = await bindSettingsPhone({ newPhone: normalizedPhone, code: normalizedCode code: normalizedCode, inviteCode: getCachedInviteCode() }) if (!isSuccessResponse(response)) { return } cacheBoundPhone(response.data, normalizedPhone) clearCachedInviteCode() uni.showToast({ title: '绑定成功', icon: 'success' traditionalApp/pages/login/login.vue
@@ -62,6 +62,7 @@ import { computed, onUnmounted, ref } from 'vue' import { appleLogin, passwordLogin, sendSmsCode, smsLogin, wechatLogin } from './service.js' import { syncJPushAliasWithCurrentUser } from '@/utils/push.js' import { clearCachedInviteCode, getCachedInviteCode } from '@/utils/invite-cache.js' const loginType = ref<'code' | 'password'>('code') const phone = ref('') @@ -104,6 +105,7 @@ accessToken ?: string openid ?: string unionid ?: string inviteCode ?: string } interface AppAppleLoginPayload { @@ -508,7 +510,8 @@ code: normalizeOptionalText(loginRes?.code), accessToken: normalizeOptionalText(authResult.access_token || authResult.accessToken), openid: normalizeOptionalText(authResult.openid || authResult.openId), unionid: normalizeOptionalText(authResult.unionid || authResult.unionId) unionid: normalizeOptionalText(authResult.unionid || authResult.unionId), inviteCode: getCachedInviteCode() } // 当前阶段优先兼容 APP 端直接返回的 accessToken/openid,后续补齐配置后也可走 code 模式。 @@ -645,6 +648,9 @@ uni.setStorageSync('token', authToken) uni.setStorageSync('appUserInfo', loginData.user || {}) uni.setStorageSync('showLogoutModal', 1) if (loginData.user?.clinicId) { clearCachedInviteCode() } // 先保留来源参数,后续如需登录后回跳可直接复用。 uni.setStorageSync('loginRedirectMeta', { inviteUserId: inviteUserId.value, traditionalApp/utils/invite-cache.js
@@ -15,10 +15,15 @@ } /** * 读取本地缓存的邀请码,只返回合法的 6 位业务码。 * 读取本地缓存的邀请码,只返回合法的 6 位业务码,历史误写入值会自动清理。 */ export const getCachedInviteCode = () => { return extractInviteCode(uni.getStorageSync(INVITE_CODE_CACHE_KEY)) const cachedValue = uni.getStorageSync(INVITE_CODE_CACHE_KEY) const inviteCode = extractInviteCode(cachedValue) if (!inviteCode && cachedValue) { clearCachedInviteCode() } return inviteCode } /** traditionalApp/utils/utils.js
@@ -258,6 +258,10 @@ * 从 URL、纯文本、对象或扫码内容中提取 6 位邀请码。 */ export const extractInviteCode = (value) => { const directInviteCode = extractObjectFieldFromAny(value, 'inviteCode') if (directInviteCode) { return normalizeInviteCodeText(directInviteCode) } const normalizedValue = normalizeInvitePayloadText(value) if (!normalizedValue) { return '' @@ -270,8 +274,7 @@ if (queryMatchedInviteCode && queryMatchedInviteCode[1]) { return normalizeInviteCodeText(decodeURIComponent(queryMatchedInviteCode[1])) } const matchedInviteCode = normalizedValue.toUpperCase().match(/[A-Z0-9]{6}/) return matchedInviteCode ? matchedInviteCode[0] : '' return normalizeInviteCodeText(normalizedValue) } /** @@ -294,26 +297,45 @@ } /** * 从对象或 JSON 字符串中读取指定字段。 */ const extractObjectFieldFromAny = (value, fieldName) => { if (!value) { return '' } if (typeof value === 'string') { return extractObjectField(value, fieldName) } if (typeof value !== 'object') { return '' } const directValue = value[fieldName] if (typeof directValue === 'string') { return directValue } const candidateKeys = ['bindData', 'data', 'channelData', 'params', 'wakeUpData'] for (const key of candidateKeys) { const nestedValue = value[key] if (!nestedValue || nestedValue === value) { continue } const nestedFieldValue = extractObjectFieldFromAny(nestedValue, fieldName) if (nestedFieldValue) { return nestedFieldValue } } return '' } /** * 从 JSON 字符串中读取指定字段。 */ const extractObjectField = (value, fieldName) => { try { const parsedValue = JSON.parse(value) if (!parsedValue || typeof parsedValue !== 'object') { return '' } const directValue = parsedValue[fieldName] if (typeof directValue === 'string') { return directValue } const bindDataValue = parsedValue.bindData || parsedValue.data || parsedValue.params if (bindDataValue && bindDataValue !== value) { return extractObjectField(normalizeInvitePayloadText(bindDataValue), fieldName) } return extractObjectFieldFromAny(JSON.parse(value), fieldName) } catch { return '' } return '' } /**