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