Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
| | |
| | | private String accessToken; |
| | | private String openid; |
| | | private String unionid; |
| | | |
| | | private String inviteCode; |
| | | } |
| | |
| | | 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; |
| | |
| | | private final AppAuthSessionService appAuthSessionService; |
| | | private final AppWechatAuthClient appWechatAuthClient; |
| | | private final AppAppleAuthClient appAppleAuthClient; |
| | | private final AppShareService appShareService; |
| | | private final StringRedisTemplate stringRedisTemplate; |
| | | |
| | | /** |
| | |
| | | AppAuthSessionService appAuthSessionService, |
| | | AppWechatAuthClient appWechatAuthClient, |
| | | AppAppleAuthClient appAppleAuthClient, |
| | | AppShareService appShareService, |
| | | StringRedisTemplate stringRedisTemplate) { |
| | | this.userMapper = userMapper; |
| | | this.appSmsCodeService = appSmsCodeService; |
| | |
| | | this.appAuthSessionService = appAuthSessionService; |
| | | this.appWechatAuthClient = appWechatAuthClient; |
| | | this.appAppleAuthClient = appAppleAuthClient; |
| | | this.appShareService = appShareService; |
| | | this.stringRedisTemplate = stringRedisTemplate; |
| | | } |
| | | |
| | |
| | | AppUser user = findUserByWechatOpenid(profile.getOpenid()); |
| | | if (user == null) { |
| | | user = buildWechatUser(profile); |
| | | bindInviteRelationIfAbsent(user, request.getInviteCode()); |
| | | userMapper.insert(user); |
| | | return createLoginResponse(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()); |
| | | } |
| | | |
| | | /** |
| | | * 基于苹果资料构造首次登录时的本地普通用户。 |
| | | */ |
| | | private AppUser buildAppleUser(AppAppleUserProfile profile) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 归一化医生邀请码,非法时忽略以保持第三方登录主流程可用。 |
| | | */ |
| | | 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) { |
| | |
| | | |
| | | @NotBlank(message = "请输入验证码") |
| | | private String code; |
| | | |
| | | private String inviteCode; |
| | | } |
| | |
| | | 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; |
| | |
| | | private final AppSmsCodeService appSmsCodeService; |
| | | private final BCryptPasswordEncoder passwordEncoder; |
| | | private final AppWechatAuthClient appWechatAuthClient; |
| | | private final AppShareService appShareService; |
| | | private final AppUserNotificationSettingMapper appUserNotificationSettingMapper; |
| | | private final ObsUploadService obsUploadService; |
| | | |
| | |
| | | AppSmsCodeService appSmsCodeService, |
| | | BCryptPasswordEncoder passwordEncoder, |
| | | AppWechatAuthClient appWechatAuthClient, |
| | | AppShareService appShareService, |
| | | AppUserNotificationSettingMapper appUserNotificationSettingMapper, |
| | | ObsUploadService obsUploadService) { |
| | | this.appAuthSessionService = appAuthSessionService; |
| | |
| | | this.appSmsCodeService = appSmsCodeService; |
| | | this.passwordEncoder = passwordEncoder; |
| | | this.appWechatAuthClient = appWechatAuthClient; |
| | | this.appShareService = appShareService; |
| | | this.appUserNotificationSettingMapper = appUserNotificationSettingMapper; |
| | | this.obsUploadService = obsUploadService; |
| | | } |
| | |
| | | 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()); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 归一化医生邀请码,非法时忽略以保持绑定手机号主流程可用。 |
| | | */ |
| | | 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) { |
| | |
| | | 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; |
| | |
| | | private AppAppleAuthClient appAppleAuthClient; |
| | | |
| | | @Mock |
| | | private AppShareService appShareService; |
| | | |
| | | @Mock |
| | | private StringRedisTemplate stringRedisTemplate; |
| | | |
| | | @Mock |
| | |
| | | appAuthSessionService, |
| | | appWechatAuthClient, |
| | | appAppleAuthClient, |
| | | appShareService, |
| | | stringRedisTemplate |
| | | ); |
| | | } |
| | |
| | | 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; |
| | |
| | | private AppWechatAuthClient appWechatAuthClient; |
| | | |
| | | @Mock |
| | | private AppShareService appShareService; |
| | | |
| | | @Mock |
| | | private AppUserNotificationSettingMapper appUserNotificationSettingMapper; |
| | | |
| | | @Mock |
| | |
| | | appSmsCodeService, |
| | | passwordEncoder, |
| | | appWechatAuthClient, |
| | | appShareService, |
| | | appUserNotificationSettingMapper, |
| | | obsUploadService |
| | | ); |
| | |
| | | <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 |
| | |
| | | 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' |
| | |
| | | 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('') |
| | |
| | | accessToken ?: string |
| | | openid ?: string |
| | | unionid ?: string |
| | | inviteCode ?: string |
| | | } |
| | | |
| | | interface AppAppleLoginPayload { |
| | |
| | | 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 模式。 |
| | |
| | | uni.setStorageSync('token', authToken) |
| | | uni.setStorageSync('appUserInfo', loginData.user || {}) |
| | | uni.setStorageSync('showLogoutModal', 1) |
| | | if (loginData.user?.clinicId) { |
| | | clearCachedInviteCode() |
| | | } |
| | | // 先保留来源参数,后续如需登录后回跳可直接复用。 |
| | | uni.setStorageSync('loginRedirectMeta', { |
| | | inviteUserId: inviteUserId.value, |
| | |
| | | } |
| | | |
| | | /** |
| | | * 读取本地缓存的邀请码,只返回合法的 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 |
| | | } |
| | | |
| | | /** |
| | |
| | | * 从 URL、纯文本、对象或扫码内容中提取 6 位邀请码。 |
| | | */ |
| | | export const extractInviteCode = (value) => { |
| | | const directInviteCode = extractObjectFieldFromAny(value, 'inviteCode') |
| | | if (directInviteCode) { |
| | | return normalizeInviteCodeText(directInviteCode) |
| | | } |
| | | const normalizedValue = normalizeInvitePayloadText(value) |
| | | if (!normalizedValue) { |
| | | return '' |
| | |
| | | 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) |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 从对象或 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 '' |
| | | } |
| | | |
| | | /** |