13404089107
2026-04-30 1cf6d1e5883cca7793884ca4151c70049cded9f0
Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
10个文件已修改
154 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppWechatLoginRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/bindPhone/bindPhone.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/login.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/invite-cache.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/utils.js 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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 ''
}
/**