From b4bb264d87478ac9006bd10f83584469a770ebd1 Mon Sep 17 00:00:00 2001
From: 13404089107 <puwei@sinata.cn>
Date: 星期四, 07 五月 2026 18:20:17 +0800
Subject: [PATCH] Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training

---
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java |   45 +++++++++++
 traditionalApp/utils/request.js                                                                        |   28 ++++++
 traditionalApp/components/invitePopup.vue                                                              |    5 
 traditionalApp/pages/settings/service.js                                                               |    4 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java     |   14 ++-
 traditionalApp/pages/login/login.vue                                                                   |   23 +++++
 traditionalApp/pages/watchLive/watchLive.vue                                                           |    5 
 traditionalApp/utils/auth-session.js                                                                   |   52 +++++++++++++
 traditionalApp/App.vue                                                                                 |    4 
 traditionalApp/pages/bindPhone/bindPhone.vue                                                           |   12 ++
 10 files changed, 171 insertions(+), 21 deletions(-)

diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java
index 3047c29..988b8ab 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java
@@ -2,6 +2,7 @@
 
 import cn.idev.excel.FastExcel;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.tcm.training.admin.common.DateTimes;
@@ -199,12 +200,13 @@
 
     @Transactional
     public UserDetailVO unbindWechat(Long id) {
-        AppUser user = requireUser(id);
-        user.setWechatOpenid(null);
-        user.setWechatNickname(null);
-        user.setWechatAvatar(null);
-        user.setWechatBoundAt(null);
-        userMapper.updateById(user);
+        requireUser(id);
+        userMapper.update(null, new LambdaUpdateWrapper<AppUser>()
+                .eq(AppUser::getId, id)
+                .set(AppUser::getWechatOpenid, null)
+                .set(AppUser::getWechatNickname, null)
+                .set(AppUser::getWechatAvatar, null)
+                .set(AppUser::getWechatBoundAt, null));
         return detail(id);
     }
 
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java
index 3134748..21d910b 100644
--- a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java
@@ -1,14 +1,23 @@
 package com.tcm.training.admin.member.service;
 
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.tcm.training.admin.common.PageResponse;
 import com.tcm.training.admin.live.mapper.ClinicMapper;
+import com.tcm.training.admin.member.entity.AppUser;
 import com.tcm.training.admin.member.mapper.UserMapper;
+import com.tcm.training.admin.member.row.UserDetailRow;
 import com.tcm.training.admin.member.row.UserListRow;
+import com.tcm.training.admin.member.vo.UserDetailVO;
 import com.tcm.training.admin.member.vo.UserListItemVO;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
@@ -19,13 +28,22 @@
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
 class UserAppServiceTest {
+
+    @BeforeAll
+    static void initLambdaCache() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, AppUser.class);
+    }
 
     @Mock
     private UserMapper userMapper;
@@ -88,4 +106,31 @@
 
         assertEquals("会员状态筛选不合法", error.getMessage());
     }
+
+    @Test
+    void unbindWechatShouldClearWechatFieldsWithExplicitNullUpdate() {
+        AppUser user = new AppUser();
+        user.setId(12L);
+        user.setDeleted(0);
+
+        UserDetailRow detailRow = new UserDetailRow();
+        detailRow.setId(12L);
+        detailRow.setNickname("张医生");
+
+        when(userMapper.selectOne(any())).thenReturn(user);
+        when(userMapper.selectUserDetail(12L)).thenReturn(detailRow);
+
+        UserDetailVO result = userAppService.unbindWechat(12L);
+
+        ArgumentCaptor<LambdaUpdateWrapper<AppUser>> wrapperCaptor = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
+        verify(userMapper).update(isNull(), wrapperCaptor.capture());
+        verify(userMapper, never()).updateById(any(AppUser.class));
+        String sqlSet = wrapperCaptor.getValue().getSqlSet();
+        assertTrue(sqlSet.contains("wechat_openid"));
+        assertTrue(sqlSet.contains("wechat_nickname"));
+        assertTrue(sqlSet.contains("wechat_avatar"));
+        assertTrue(sqlSet.contains("wechat_bound_at"));
+        assertTrue(wrapperCaptor.getValue().getParamNameValuePairs().values().stream().allMatch(value -> value == null));
+        assertEquals(Long.valueOf(12L), result.getId());
+    }
 }
diff --git a/traditionalApp/App.vue b/traditionalApp/App.vue
index 010bd74..e4e6a8a 100644
--- a/traditionalApp/App.vue
+++ b/traditionalApp/App.vue
@@ -7,7 +7,7 @@
 		extractBindTicket,
 		extractInviteCode
 	} from '@/utils/utils.js'
-	import { hasBoundPhone, hasStoredSession } from '@/utils/auth-session.js'
+	import { hasCompletedAuthSession } from '@/utils/auth-session.js'
 	import { cacheInviteCode } from '@/utils/invite-cache.js'
 	import { initJPushLifecycle } from '@/utils/push.js'
 	import {
@@ -71,7 +71,7 @@
 			return
 		}
 		lastHandledInvitePayload = payloadKey
-		if (hasStoredSession() && hasBoundPhone()) {
+		if (hasCompletedAuthSession()) {
 			return
 		}
 		const currentPages = getCurrentPages()
diff --git a/traditionalApp/components/invitePopup.vue b/traditionalApp/components/invitePopup.vue
index e5521e8..2fdd203 100644
--- a/traditionalApp/components/invitePopup.vue
+++ b/traditionalApp/components/invitePopup.vue
@@ -32,6 +32,7 @@
 <script setup lang="ts">
 import { ref, watch } from "vue";
 import { confirmSuperior, getSuperiorInfo } from "./service.js";
+import { hasCompletedAuthSession } from '@/utils/auth-session.js'
 interface Props {
     show: boolean;
     code?: string;
@@ -63,7 +64,7 @@
 });
 
 const handleAccept = () => {
-    if (!uni.getStorageSync('token')) {
+    if (!hasCompletedAuthSession()) {
         uni.navigateTo({
             url: '/pages/login/login?inviteUserId=' + superiorInfo.value.id + '&inviteType=' + props.type
         })
@@ -177,4 +178,4 @@
         transform: translateY(-50%);
     }
 }
-</style>
\ No newline at end of file
+</style>
diff --git a/traditionalApp/pages/bindPhone/bindPhone.vue b/traditionalApp/pages/bindPhone/bindPhone.vue
index 8beabff..42babcb 100644
--- a/traditionalApp/pages/bindPhone/bindPhone.vue
+++ b/traditionalApp/pages/bindPhone/bindPhone.vue
@@ -45,8 +45,9 @@
 <script setup lang="ts">
 import { ref } from 'vue'
 import { bindSettingsPhone, sendBindPhoneCode } from './service.js'
-import { saveAuthSession } from '@/utils/auth-session.js'
+import { clearAuthSession, saveAuthSession } from '@/utils/auth-session.js'
 import { clearCachedInviteCode, getCachedInviteCode } from '@/utils/invite-cache.js'
+import { clearJPushAliasBinding } from '@/utils/push.js'
 
 interface ApiResponse<T> {
 	code: number
@@ -89,6 +90,7 @@
 		isFocused.value = false
 		return
 	}
+	clearPendingLoginSession()
 	uni.redirectTo({
 		url: '/pages/login/login'
 	})
@@ -246,6 +248,14 @@
 }
 
 /**
+ * 放弃绑定手机号时清理第三方临时登录态,避免返回后被识别为已登录。
+ */
+const clearPendingLoginSession = () => {
+	clearAuthSession()
+	clearJPushAliasBinding()
+}
+
+/**
  * 判断接口响应是否为成功态。
  */
 const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => {
diff --git a/traditionalApp/pages/login/login.vue b/traditionalApp/pages/login/login.vue
index 2ca5da5..40276d2 100644
--- a/traditionalApp/pages/login/login.vue
+++ b/traditionalApp/pages/login/login.vue
@@ -61,7 +61,7 @@
 	import { onLoad } from '@dcloudio/uni-app'
 	import { computed, onUnmounted, ref } from 'vue'
 	import { appleLogin, passwordLogin, sendSmsCode, smsLogin, wechatLogin } from './service.js'
-	import { saveAuthSession } from '@/utils/auth-session.js'
+	import { saveAuthSession, savePendingBindSession } from '@/utils/auth-session.js'
 	import { syncJPushAliasWithCurrentUser } from '@/utils/push.js'
 	import { clearCachedInviteCode, getCachedInviteCode } from '@/utils/invite-cache.js'
 
@@ -251,13 +251,14 @@
 			if (!isSuccessResponse(res)) {
 				return
 			}
-			saveLoginSession(res.data)
 			if (shouldBindPhone(res.data)) {
+				savePendingBindPhoneSession(res.data)
 				uni.redirectTo({
 					url: '/pages/bindPhone/bindPhone'
 				})
 				return
 			}
+			saveLoginSession(res.data)
 			uni.showToast({
 				title: '登录成功',
 				icon: 'success'
@@ -315,13 +316,14 @@
 			if (!isSuccessResponse(res)) {
 				return
 			}
-			saveLoginSession(res.data)
 			if (shouldBindPhone(res.data)) {
+				savePendingBindPhoneSession(res.data)
 				uni.redirectTo({
 					url: '/pages/bindPhone/bindPhone'
 				})
 				return
 			}
+			saveLoginSession(res.data)
 			uni.showToast({
 				title: '登录成功',
 				icon: 'success'
@@ -668,6 +670,21 @@
 	}
 
 	/**
+	 * 保存第三方登录待绑定手机号临时会话,仅供绑定手机号接口鉴权使用。
+	 */
+	const savePendingBindPhoneSession = (loginData : AppLoginPayload) => {
+		if (!loginData) {
+			return
+		}
+		savePendingBindSession(loginData, {
+			redirectMeta: {
+				inviteUserId: inviteUserId.value,
+				inviteType: inviteType.value
+			}
+		})
+	}
+
+	/**
 	 * 判断第三方登录返回用户是否缺少手机号,缺少时进入绑定手机号流程。
 	 */
 	const shouldBindPhone = (loginData : AppLoginPayload) => {
diff --git a/traditionalApp/pages/settings/service.js b/traditionalApp/pages/settings/service.js
index cc53135..a2638d8 100644
--- a/traditionalApp/pages/settings/service.js
+++ b/traditionalApp/pages/settings/service.js
@@ -1,5 +1,6 @@
 import config from '@/config/index.js'
 import request from '@/utils/request.js'
+import { getCompletedAuthHeader } from '@/utils/auth-session.js'
 
 /**
  * 获取 APP 设置页个人资料。
@@ -76,14 +77,13 @@
  */
 export const uploadSettingsAvatar = (filePath) => {
 	return new Promise((resolve, reject) => {
-		const token = uni.getStorageSync('token')
 		uni.uploadFile({
 			// 头像上传走 APP 专用接口,避免命中后台管理员鉴权链路。
 			url: `${config.BASE_URL}/api/app/settings/upload/avatar`,
 			filePath,
 			name: 'file',
 			header: {
-				Authorization: token || '',
+				Authorization: getCompletedAuthHeader(),
 			},
 			success: (res) => {
 				try {
diff --git a/traditionalApp/pages/watchLive/watchLive.vue b/traditionalApp/pages/watchLive/watchLive.vue
index 7dbbb8c..a1084c0 100644
--- a/traditionalApp/pages/watchLive/watchLive.vue
+++ b/traditionalApp/pages/watchLive/watchLive.vue
@@ -162,6 +162,7 @@
 	import { getLiveCourseDetail, getLiveQuizStatus, submitLiveQuiz, uploadLiveWatchRecord } from './service';
 	import { getHelpCenter } from '../helpCenter/service';
 	import config from '@/config/index.js';
+	import { hasCompletedAuthSession } from '@/utils/auth-session.js';
 
 	type LiveActionStatus = 'RESERVABLE' | 'RESERVED' | 'WATCHABLE' | 'CLOSED'
 	type QuizViewState = 'answering' | 'success' | 'alreadyAwarded' | 'wrong' | 'unopened'
@@ -476,10 +477,10 @@
 	}
 
 	/**
-	 * 判断当前设备是否已存在 APP 登录 token。
+	 * 判断当前设备是否已存在完整 APP 登录态。
 	 */
 	function hasLoginToken() : boolean {
-		return !!uni.getStorageSync('token')
+		return hasCompletedAuthSession()
 	}
 
 	const pageTitle = computed(() => liveDetail.value.title || '直播观看')
diff --git a/traditionalApp/utils/auth-session.js b/traditionalApp/utils/auth-session.js
index d52aa39..2a0e637 100644
--- a/traditionalApp/utils/auth-session.js
+++ b/traditionalApp/utils/auth-session.js
@@ -3,6 +3,7 @@
 const TOKEN_EXPIRE_AT_KEY = 'tokenExpireAt'
 const LOGOUT_MODAL_KEY = 'showLogoutModal'
 const REDIRECT_META_KEY = 'loginRedirectMeta'
+const PENDING_PHONE_BIND_KEY = 'pendingPhoneBind'
 
 /**
  * 读取当前缓存中的 APP token。
@@ -17,6 +18,13 @@
  */
 export function hasStoredSession() {
 	return !!getStoredToken()
+}
+
+/**
+ * 判断当前是否存在完整 APP 登录态。
+ */
+export function hasCompletedAuthSession() {
+	return hasStoredSession() && hasBoundPhone() && !hasPendingPhoneBindSession()
 }
 
 /**
@@ -36,12 +44,55 @@
 }
 
 /**
+ * 判断当前缓存是否只是待绑定手机号的第三方临时会话。
+ */
+export function hasPendingPhoneBindSession() {
+	return uni.getStorageSync(PENDING_PHONE_BIND_KEY) === 1
+}
+
+/**
+ * 读取完整登录态可用的鉴权头;临时绑定会话不对普通业务接口暴露 token。
+ */
+export function getCompletedAuthHeader() {
+	if (!hasCompletedAuthSession()) {
+		return ''
+	}
+	return getStoredToken()
+}
+
+/**
  * 从登录响应中持久化 APP 登录态。
  */
 export function saveAuthSession(loginData, extras = {}) {
 	if (!loginData || typeof loginData !== 'object') {
 		return
 	}
+	persistSession(loginData, extras)
+	clearPendingBindSession()
+}
+
+/**
+ * 保存仅用于绑定手机号流程的第三方临时会话。
+ */
+export function savePendingBindSession(loginData, extras = {}) {
+	if (!loginData || typeof loginData !== 'object') {
+		return
+	}
+	persistSession(loginData, extras)
+	uni.setStorageSync(PENDING_PHONE_BIND_KEY, 1)
+}
+
+/**
+ * 清除待绑定手机号临时会话标记。
+ */
+export function clearPendingBindSession() {
+	uni.removeStorageSync(PENDING_PHONE_BIND_KEY)
+}
+
+/**
+ * 持久化 APP 会话公共字段。
+ */
+function persistSession(loginData, extras = {}) {
 	const tokenValue = typeof loginData.token === 'string' ? loginData.token.trim() : ''
 	if (tokenValue) {
 		const tokenType = typeof loginData.tokenType === 'string' && loginData.tokenType.trim() ? loginData.tokenType.trim() : 'Bearer'
@@ -98,5 +149,6 @@
 	uni.removeStorageSync(USER_KEY)
 	uni.removeStorageSync(TOKEN_EXPIRE_AT_KEY)
 	uni.removeStorageSync(REDIRECT_META_KEY)
+	uni.removeStorageSync(PENDING_PHONE_BIND_KEY)
 	uni.setStorageSync(LOGOUT_MODAL_KEY, 0)
 }
diff --git a/traditionalApp/utils/request.js b/traditionalApp/utils/request.js
index be8e741..7d8e61c 100644
--- a/traditionalApp/utils/request.js
+++ b/traditionalApp/utils/request.js
@@ -4,6 +4,7 @@
 	clearAuthSession,
 	getStoredToken,
 	hasBoundPhone,
+	hasPendingPhoneBindSession,
 	saveAuthSession,
 	willTokenExpireWithin,
 } from '@/utils/auth-session.js'
@@ -17,6 +18,10 @@
 const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000
 const SUCCESS_CODES = [0, 200]
 const UNAUTHORIZED_CODES = [401, 504, 505, 510]
+const PENDING_BIND_ALLOWED_URLS = [
+	'/api/app/settings/phone/code',
+	'/api/app/settings/phone/bind',
+]
 
 const waitingList = []
 const excutingList = []
@@ -53,12 +58,29 @@
 /**
  * 构造当前请求头。
  */
-function buildHeaders(type) {
+function buildHeaders(type, url) {
 	return {
 		'content-type': type ? 'application/x-www-form-urlencoded;charset=UTF-8' : 'application/json',
-		Authorization: getStoredToken(),
+		Authorization: resolveAuthorizationHeader(url),
 		lang: resolveLangHeader(),
 	}
+}
+
+/**
+ * 解析请求鉴权头,待绑定手机号临时 token 仅允许访问绑定手机号接口。
+ */
+function resolveAuthorizationHeader(url) {
+	if (!hasPendingPhoneBindSession()) {
+		return getStoredToken()
+	}
+	return isPendingBindAllowedUrl(url) ? getStoredToken() : ''
+}
+
+/**
+ * 判断当前接口是否允许使用待绑定手机号临时 token。
+ */
+function isPendingBindAllowedUrl(url) {
+	return PENDING_BIND_ALLOWED_URLS.includes(url)
 }
 
 /**
@@ -88,7 +110,7 @@
 				url: `${baseURL}${url}`,
 				method,
 				data,
-				header: buildHeaders(type),
+				header: buildHeaders(type, url),
 				success: (res) => {
 					if (lodingFlag) {
 						uni.hideLoading()

--
Gitblit v1.7.1