Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
| | |
| | | |
| | | 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; |
| | |
| | | |
| | | @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); |
| | | } |
| | | |
| | |
| | | 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; |
| | |
| | | |
| | | 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; |
| | |
| | | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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 { |
| | |
| | | return |
| | | } |
| | | lastHandledInvitePayload = payloadKey |
| | | if (hasStoredSession() && hasBoundPhone()) { |
| | | if (hasCompletedAuthSession()) { |
| | | return |
| | | } |
| | | const currentPages = getCurrentPages() |
| | |
| | | <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; |
| | |
| | | }); |
| | | |
| | | const handleAccept = () => { |
| | | if (!uni.getStorageSync('token')) { |
| | | if (!hasCompletedAuthSession()) { |
| | | uni.navigateTo({ |
| | | url: '/pages/login/login?inviteUserId=' + superiorInfo.value.id + '&inviteType=' + props.type |
| | | }) |
| | |
| | | <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 |
| | |
| | | isFocused.value = false |
| | | return |
| | | } |
| | | clearPendingLoginSession() |
| | | uni.redirectTo({ |
| | | url: '/pages/login/login' |
| | | }) |
| | |
| | | } |
| | | |
| | | /** |
| | | * 放弃绑定手机号时清理第三方临时登录态,避免返回后被识别为已登录。 |
| | | */ |
| | | const clearPendingLoginSession = () => { |
| | | clearAuthSession() |
| | | clearJPushAliasBinding() |
| | | } |
| | | |
| | | /** |
| | | * 判断接口响应是否为成功态。 |
| | | */ |
| | | const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => { |
| | |
| | | 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' |
| | | |
| | |
| | | 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' |
| | |
| | | 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' |
| | |
| | | } |
| | | |
| | | /** |
| | | * 保存第三方登录待绑定手机号临时会话,仅供绑定手机号接口鉴权使用。 |
| | | */ |
| | | const savePendingBindPhoneSession = (loginData : AppLoginPayload) => { |
| | | if (!loginData) { |
| | | return |
| | | } |
| | | savePendingBindSession(loginData, { |
| | | redirectMeta: { |
| | | inviteUserId: inviteUserId.value, |
| | | inviteType: inviteType.value |
| | | } |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 判断第三方登录返回用户是否缺少手机号,缺少时进入绑定手机号流程。 |
| | | */ |
| | | const shouldBindPhone = (loginData : AppLoginPayload) => { |
| | |
| | | import config from '@/config/index.js' |
| | | import request from '@/utils/request.js' |
| | | import { getCompletedAuthHeader } from '@/utils/auth-session.js' |
| | | |
| | | /** |
| | | * 获取 APP 设置页个人资料。 |
| | |
| | | */ |
| | | 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 { |
| | |
| | | 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' |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断当前设备是否已存在 APP 登录 token。 |
| | | * 判断当前设备是否已存在完整 APP 登录态。 |
| | | */ |
| | | function hasLoginToken() : boolean { |
| | | return !!uni.getStorageSync('token') |
| | | return hasCompletedAuthSession() |
| | | } |
| | | |
| | | const pageTitle = computed(() => liveDetail.value.title || '直播观看') |
| | |
| | | const TOKEN_EXPIRE_AT_KEY = 'tokenExpireAt' |
| | | const LOGOUT_MODAL_KEY = 'showLogoutModal' |
| | | const REDIRECT_META_KEY = 'loginRedirectMeta' |
| | | const PENDING_PHONE_BIND_KEY = 'pendingPhoneBind' |
| | | |
| | | /** |
| | | * 读取当前缓存中的 APP token。 |
| | |
| | | */ |
| | | export function hasStoredSession() { |
| | | return !!getStoredToken() |
| | | } |
| | | |
| | | /** |
| | | * 判断当前是否存在完整 APP 登录态。 |
| | | */ |
| | | export function hasCompletedAuthSession() { |
| | | return hasStoredSession() && hasBoundPhone() && !hasPendingPhoneBindSession() |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断当前缓存是否只是待绑定手机号的第三方临时会话。 |
| | | */ |
| | | 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' |
| | |
| | | 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) |
| | | } |
| | |
| | | clearAuthSession, |
| | | getStoredToken, |
| | | hasBoundPhone, |
| | | hasPendingPhoneBindSession, |
| | | saveAuthSession, |
| | | willTokenExpireWithin, |
| | | } from '@/utils/auth-session.js' |
| | |
| | | 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 = [] |
| | |
| | | /** |
| | | * 构造当前请求头。 |
| | | */ |
| | | 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) |
| | | } |
| | | |
| | | /** |
| | |
| | | url: `${baseURL}${url}`, |
| | | method, |
| | | data, |
| | | header: buildHeaders(type), |
| | | header: buildHeaders(type, url), |
| | | success: (res) => { |
| | | if (lodingFlag) { |
| | | uni.hideLoading() |