13404089107
2026-05-07 b4bb264d87478ac9006bd10f83584469a770ebd1
Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
10个文件已修改
190 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/App.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/components/invitePopup.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/bindPhone/bindPhone.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/login.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/settings/service.js 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLive/watchLive.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/auth-session.js 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/request.js 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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);
    }
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());
    }
}
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()
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
        })
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) => {
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) => {
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 {
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 || '直播观看')
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)
}
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()