From e90b453b75cc3f91fe0e938b5cb2dbe37fa06bb1 Mon Sep 17 00:00:00 2001
From: “mitao” <“2763622819@qq.com”>
Date: 星期四, 30 四月 2026 19:12:00 +0800
Subject: [PATCH] feat:邀请码回填问题

---
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppWechatLoginRequest.java           |    2 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java      |   33 +++++++++++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java              |   33 +++++++++++
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java          |    5 +
 traditionalApp/pages/login/login.vue                                                                              |    8 ++
 traditionalApp/utils/utils.js                                                                                     |   52 ++++++++++++-----
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java |    2 
 traditionalApp/pages/bindPhone/bindPhone.vue                                                                      |    5 +
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java  |    5 +
 traditionalApp/utils/invite-cache.js                                                                              |    9 ++
 10 files changed, 135 insertions(+), 19 deletions(-)

diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppWechatLoginRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppWechatLoginRequest.java
index 628ea67..f9b56e3 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppWechatLoginRequest.java
+++ b/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;
 }
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java
index 2e2c384..bec0650 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java
+++ b/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) {
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java
index 43b4be6..ca3d034 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java
+++ b/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;
 }
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
index 0af4fed..a8c3ed9 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
+++ b/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) {
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java
index 6cd7be9..b3b383d 100644
--- a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java
+++ b/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
         );
     }
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
index b695bf2..8300e88 100644
--- a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
+++ b/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
         );
diff --git a/traditionalApp/pages/bindPhone/bindPhone.vue b/traditionalApp/pages/bindPhone/bindPhone.vue
index ac019cd..d2a0b18 100644
--- a/traditionalApp/pages/bindPhone/bindPhone.vue
+++ b/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'
diff --git a/traditionalApp/pages/login/login.vue b/traditionalApp/pages/login/login.vue
index f11e021..d44b0d9 100644
--- a/traditionalApp/pages/login/login.vue
+++ b/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,
diff --git a/traditionalApp/utils/invite-cache.js b/traditionalApp/utils/invite-cache.js
index e72e208..ea4d6a7 100644
--- a/traditionalApp/utils/invite-cache.js
+++ b/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
 }
 
 /**
diff --git a/traditionalApp/utils/utils.js b/traditionalApp/utils/utils.js
index 7032f75..ba40388 100644
--- a/traditionalApp/utils/utils.js
+++ b/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 ''
 }
 
 /**

--
Gitblit v1.7.1