| | |
| | | appAuthService.logout(); |
| | | return ApiResponse.ok(null); |
| | | } |
| | | |
| | | /** |
| | | * 刷新当前 APP 登录态有效期。 |
| | | */ |
| | | @PostMapping("/refresh-token") |
| | | public ApiResponse<AppLoginResponse> refreshToken() { |
| | | return ApiResponse.ok(appAuthService.refreshToken()); |
| | | } |
| | | } |
| | |
| | | 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.auth.exception.InviteCodeRequiredException; |
| | | 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; |
| | |
| | | private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$"); |
| | | private static final String PASSWORD_RESET_TICKET_CACHE_PREFIX = "app:password-reset:ticket:"; |
| | | private static final long PASSWORD_RESET_TICKET_EXPIRE_MINUTES = 10L; |
| | | |
| | | private final UserMapper userMapper; |
| | | private final AppSmsCodeService appSmsCodeService; |
| | | private final BCryptPasswordEncoder passwordEncoder; |
| | |
| | | AppWechatUserProfile profile = appWechatAuthClient.resolveUserProfile(request); |
| | | AppUser user = findUserByWechatOpenid(profile.getOpenid()); |
| | | if (user == null) { |
| | | requireInviteCodeForThirdPartyRegister(request.getInviteCode()); |
| | | user = buildWechatUser(profile); |
| | | bindInviteRelationIfAbsent(user, request.getInviteCode()); |
| | | userMapper.insert(user); |
| | |
| | | AppAppleUserProfile profile = appAppleAuthClient.resolveUserProfile(request); |
| | | AppUser user = findUserByAppleSubject(profile.getSubject()); |
| | | if (user == null) { |
| | | requireInviteCodeForThirdPartyRegister(request.getInviteCode()); |
| | | user = buildAppleUser(profile); |
| | | bindInviteRelationIfAbsent(user, request.getInviteCode()); |
| | | userMapper.insert(user); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 刷新当前 APP 登录态,并重新签发一枚新的 token。 |
| | | */ |
| | | public AppLoginResponse refreshToken() { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | AppUser user = requireEnabledUserById(userId); |
| | | // 当前 Sa-Token 版本不支持按 token 原地续期,这里直接为当前用户重新签发新 token。 |
| | | appAuthSessionService.login(user.getId()); |
| | | return buildLoginResponse(user, appAuthSessionService.getTokenInfo()); |
| | | } |
| | | |
| | | /** |
| | | * 为成功登录的 APP 用户建立登录态并拼装统一响应。 |
| | | */ |
| | | private AppLoginResponse createLoginResponse(AppUser user) { |
| | | appAuthSessionService.login(user.getId()); |
| | | SaTokenInfo tokenInfo = appAuthSessionService.getTokenInfo(); |
| | | return buildLoginResponse(user, appAuthSessionService.getTokenInfo()); |
| | | } |
| | | |
| | | /** |
| | | * 统一构造 APP 登录响应,保证登录与续期接口返回结构一致。 |
| | | */ |
| | | private AppLoginResponse buildLoginResponse(AppUser user, SaTokenInfo tokenInfo) { |
| | | return new AppLoginResponse( |
| | | tokenInfo.getTokenValue(), |
| | | "Bearer", |
| | |
| | | */ |
| | | private AppUser requireEnabledUserByPhone(String phone) { |
| | | AppUser user = requireExistingUserByPhone(phone); |
| | | requireEnabledUser(user); |
| | | return user; |
| | | } |
| | | |
| | | /** |
| | | * 按用户主键查询并校验 APP 用户状态。 |
| | | */ |
| | | private AppUser requireEnabledUserById(Long userId) { |
| | | AppUser user = userMapper.selectById(userId); |
| | | if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) { |
| | | throw new IllegalArgumentException("用户不存在"); |
| | | } |
| | | requireEnabledUser(user); |
| | | return user; |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 第三方授权首次创建账号时必须携带医生邀请码。 |
| | | */ |
| | | private void requireInviteCodeForThirdPartyRegister(String inviteCode) { |
| | | if (normalizeInviteCode(inviteCode) == null) { |
| | | throw new InviteCodeRequiredException(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。 |
| | | */ |
| | | private void bindInviteRelationIfAbsent(AppUser user, String inviteCode) { |
| | |
| | | private void applyWechatProfile(AppUser user, AppWechatUserProfile profile, boolean syncLocalProfile) { |
| | | String nickname = trimToNull(profile.getNickname()); |
| | | String avatar = trimToNull(profile.getAvatar()); |
| | | String currentNickname = trimToNull(user.getNickname()); |
| | | |
| | | user.setWechatOpenid(profile.getOpenid()); |
| | | user.setWechatNickname(nickname); |
| | | user.setWechatAvatar(avatar); |
| | | user.setWechatBoundAt(LocalDateTime.now()); |
| | | |
| | | if (syncLocalProfile || trimToNull(user.getNickname()) == null) { |
| | | if (shouldRefreshLocalNickname(currentNickname, profile.getOpenid(), nickname, syncLocalProfile)) { |
| | | user.setNickname(nickname == null ? profile.getOpenid() : nickname); |
| | | } |
| | | if (syncLocalProfile || trimToNull(user.getAvatar()) == null) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 仅在首次建号、昵称为空、openid 兜底值或历史乱码场景下刷新本地昵称,避免覆盖用户手动修改结果。 |
| | | */ |
| | | private boolean shouldRefreshLocalNickname(String currentNickname, |
| | | String openid, |
| | | String latestWechatNickname, |
| | | boolean syncLocalProfile) { |
| | | if (syncLocalProfile) { |
| | | return true; |
| | | } |
| | | if (trimToNull(latestWechatNickname) == null) { |
| | | return false; |
| | | } |
| | | String normalizedCurrentNickname = trimToNull(currentNickname); |
| | | if (normalizedCurrentNickname == null) { |
| | | return true; |
| | | } |
| | | String normalizedOpenid = trimToNull(openid); |
| | | if (normalizedOpenid != null && normalizedOpenid.equals(normalizedCurrentNickname)) { |
| | | return true; |
| | | } |
| | | return containsNicknameMojibake(normalizedCurrentNickname); |
| | | } |
| | | |
| | | /** |
| | | * 识别历史微信昵称乱码的常见特征,限定为已知乱码模式,避免误伤正常昵称。 |
| | | */ |
| | | private boolean containsNicknameMojibake(String nickname) { |
| | | return nickname.contains("ð") |
| | | || nickname.contains("Ã") |
| | | || nickname.contains("â") |
| | | || nickname.contains("�"); |
| | | } |
| | | |
| | | /** |
| | | * 将苹果资料回填到本地用户,并在首次自动建号时补齐基础展示昵称。 |
| | | */ |
| | | private void applyAppleProfile(AppUser user, AppAppleUserProfile profile, boolean syncLocalProfile) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 获取当前请求携带的 APP token 值。 |
| | | */ |
| | | public String getTokenValue() { |
| | | return StpAppUserUtil.getTokenValue(); |
| | | } |
| | | |
| | | /** |
| | | * 注销当前 APP 登录态。 |
| | | */ |
| | | public void logout() { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 获取底层 APP 登录态逻辑对象,供需要调用 Sa-Token 高级能力的服务复用。 |
| | | */ |
| | | public static StpLogic getStpLogic() { |
| | | return STP_LOGIC; |
| | | } |
| | | |
| | | /** |
| | | * 获取当前请求携带的 token 值。 |
| | | */ |
| | | public static String getTokenValue() { |
| | | return STP_LOGIC.getTokenValue(); |
| | | } |
| | | |
| | | /** |
| | | * 注销当前 APP 登录态。 |
| | | */ |
| | | public static void logout() { |
| | |
| | | import com.tcm.training.admin.app.auth.service.AppSmsCodeService; |
| | | import com.tcm.training.admin.app.auth.service.AppWechatAuthClient; |
| | | import com.tcm.training.admin.app.auth.service.AppWechatUserProfile; |
| | | import com.tcm.training.admin.app.auth.exception.InviteCodeRequiredException; |
| | | import com.tcm.training.admin.app.settings.dto.AppSettingsNotificationUpdateRequest; |
| | | import com.tcm.training.admin.app.settings.dto.AppSettingsPasswordUpdateRequest; |
| | | import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest; |
| | |
| | | } |
| | | AppUser occupiedUser = findUserByPhone(newPhone); |
| | | validatePhoneBindTarget(user, occupiedUser); |
| | | requireInviteCodeWhenDoctorRelationMissing(user, occupiedUser, request.getInviteCode()); |
| | | appSmsCodeService.validatePhoneChangeCode(newPhone, code); |
| | | AppUser loginUser = occupiedUser == null |
| | | ? bindPhoneToCurrentUser(user, newPhone, request.getInviteCode()) |
| | |
| | | requireEnabledUser(targetUser); |
| | | copyThirdPartyBinding(currentUser, targetUser); |
| | | fillMissingProfile(targetUser, currentUser); |
| | | inheritDoctorRelationIfAbsent(targetUser, currentUser); |
| | | bindInviteRelationIfAbsent(targetUser, inviteCode); |
| | | userMapper.updateById(targetUser); |
| | | disableMergedTemporaryUser(currentUser); |
| | | return targetUser; |
| | | } |
| | | |
| | | /** |
| | | * 当临时账号和目标手机号账号都没有医生关系时,绑定手机号阶段必须携带邀请码。 |
| | | */ |
| | | private void requireInviteCodeWhenDoctorRelationMissing(AppUser currentUser, AppUser targetUser, String inviteCode) { |
| | | if (hasDoctorRelation(currentUser) || hasDoctorRelation(targetUser)) { |
| | | return; |
| | | } |
| | | if (normalizeInviteCode(inviteCode) == null) { |
| | | throw new InviteCodeRequiredException(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 合并到已有手机号账号时,优先继承临时账号上已存在的医生关系。 |
| | | */ |
| | | private void inheritDoctorRelationIfAbsent(AppUser targetUser, AppUser sourceUser) { |
| | | if (hasDoctorRelation(targetUser) || !hasDoctorRelation(sourceUser)) { |
| | | return; |
| | | } |
| | | targetUser.setClinicId(sourceUser.getClinicId()); |
| | | targetUser.setSuperiorClinicUserId(sourceUser.getSuperiorClinicUserId()); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断账号是否已绑定医生上下级关系。 |
| | | */ |
| | | private boolean hasDoctorRelation(AppUser user) { |
| | | return user != null && (user.getClinicId() != null || user.getSuperiorClinicUserId() != null); |
| | | } |
| | | |
| | | /** |
| | | * 为绑定后的用户签发新的 APP 登录态。 |
| | | */ |
| | | private AppLoginResponse createLoginResponse(AppUser user) { |
| | |
| | | extractBindTicket, |
| | | extractInviteCode |
| | | } from '@/utils/utils.js' |
| | | import { hasBoundPhone, hasStoredSession } 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()) { |
| | | return |
| | | } |
| | | const currentPages = getCurrentPages() |
| | | const currentRoute = currentPages && currentPages.length > 0 ? currentPages[currentPages.length - 1].route : '' |
| | | const targetParams = [] |
| | |
| | | <script setup lang="ts"> |
| | | import { ref } from 'vue' |
| | | import { bindSettingsPhone, sendBindPhoneCode } from './service.js' |
| | | import { saveAuthSession } from '@/utils/auth-session.js' |
| | | import { clearCachedInviteCode, getCachedInviteCode } from '@/utils/invite-cache.js' |
| | | |
| | | interface ApiResponse<T> { |
| | |
| | | code: normalizedCode, |
| | | inviteCode: getCachedInviteCode() |
| | | }) |
| | | if (shouldShowInviteRequiredGuide(response)) { |
| | | showInviteRequiredGuide() |
| | | return |
| | | } |
| | | if (!isSuccessResponse(response)) { |
| | | return |
| | | } |
| | |
| | | * 绑定成功后保存后端返回的新登录态,确保后续请求切到正式账号。 |
| | | */ |
| | | const saveLoginSession = (loginData: AppLoginPayload | undefined, fallbackPhone: string) => { |
| | | const userInfo = loginData?.user || {} |
| | | const tokenValue = normalizeOptionalText(loginData?.token) |
| | | if (tokenValue) { |
| | | const authToken = `${loginData?.tokenType || 'Bearer'} ${tokenValue}`.trim() |
| | | uni.setStorageSync('token', authToken) |
| | | } |
| | | uni.setStorageSync('appUserInfo', { |
| | | ...userInfo, |
| | | phone: normalizeOptionalText(userInfo.phone) || fallbackPhone |
| | | saveAuthSession(loginData, { |
| | | userOverrides: { |
| | | phone: normalizeOptionalText(loginData?.user?.phone) || fallbackPhone |
| | | } |
| | | }) |
| | | uni.setStorageSync('showLogoutModal', 1) |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断是否需要展示缺少邀请码引导弹窗。 |
| | | */ |
| | | const shouldShowInviteRequiredGuide = (response: ApiResponse<unknown> | undefined) => { |
| | | return !!response && response.code === 506 && normalizeOptionalText(response.msg || response.message) === '暂无邀请码,请联系管理员获取' |
| | | } |
| | | |
| | | /** |
| | | * 展示缺少邀请码弹窗,关闭后跳转到邀请码说明详情。 |
| | | */ |
| | | const showInviteRequiredGuide = () => { |
| | | uni.showModal({ |
| | | title: '提示', |
| | | content: '暂无邀请码,请联系管理员获取', |
| | | showCancel: false, |
| | | confirmText: '我知道了', |
| | | success: () => { |
| | | uni.navigateTo({ |
| | | url: '/pages/help/help?type=invite' |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 将可选文本归一化为空字符串。 |
| | | */ |
| | | const normalizeOptionalText = (value: unknown) => { |
| | |
| | | return new Promise<void>((resolve) => { |
| | | const token = uni.getStorageSync('token') |
| | | const appUserInfo = uni.getStorageSync('appUserInfo') |
| | | const tokenExpireAt = uni.getStorageSync('tokenExpireAt') |
| | | const loginRedirectMeta = uni.getStorageSync('loginRedirectMeta') |
| | | const showLogoutModal = uni.getStorageSync('showLogoutModal') |
| | | const inviteCodeCacheKey = getInviteCodeCacheKey() |
| | |
| | | if (appUserInfo) { |
| | | uni.setStorageSync('appUserInfo', appUserInfo) |
| | | } |
| | | if (tokenExpireAt) { |
| | | uni.setStorageSync('tokenExpireAt', tokenExpireAt) |
| | | } |
| | | if (loginRedirectMeta) { |
| | | uni.setStorageSync('loginRedirectMeta', loginRedirectMeta) |
| | | } |
| | |
| | | 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 { syncJPushAliasWithCurrentUser } from '@/utils/push.js' |
| | | import { clearCachedInviteCode, getCachedInviteCode } from '@/utils/invite-cache.js' |
| | | |
| | |
| | | if (!loginData) { |
| | | return |
| | | } |
| | | const authToken = `${loginData.tokenType || 'Bearer'} ${loginData.token || ''}`.trim() |
| | | uni.setStorageSync('token', authToken) |
| | | uni.setStorageSync('appUserInfo', loginData.user || {}) |
| | | uni.setStorageSync('showLogoutModal', 1) |
| | | saveAuthSession(loginData, { |
| | | redirectMeta: { |
| | | inviteUserId: inviteUserId.value, |
| | | inviteType: inviteType.value |
| | | } |
| | | }) |
| | | if (loginData.user?.clinicId) { |
| | | clearCachedInviteCode() |
| | | } |
| | | // 先保留来源参数,后续如需登录后回跳可直接复用。 |
| | | uni.setStorageSync('loginRedirectMeta', { |
| | | inviteUserId: inviteUserId.value, |
| | | inviteType: inviteType.value |
| | | }) |
| | | syncJPushAliasWithCurrentUser() |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 刷新当前 APP 登录态有效期。 |
| | | */ |
| | | export const refreshAuthToken = () => { |
| | | return request.post('/api/app/auth/refresh-token', {}, false, false) |
| | | } |
| | | |
| | | /** |
| | | * 确认上级用户邀请。 |
| | | */ |
| | | export const confirmSuperior = (params) => { |
| | |
| | | import { normalizeOptionalText } from '@/utils/utils.js' |
| | | import { clearJPushAliasBinding } from '@/utils/push.js' |
| | | import { clearAuthSession } from '@/utils/auth-session.js' |
| | | |
| | | /** |
| | | * 判断接口响应是否成功。 |
| | |
| | | * 清理登录态缓存,保持与请求拦截器的处理一致。 |
| | | */ |
| | | export const clearSettingsLoginSession = () => { |
| | | uni.removeStorageSync('token') |
| | | uni.removeStorageSync('appUserInfo') |
| | | uni.removeStorageSync('loginRedirectMeta') |
| | | uni.setStorageSync('showLogoutModal', 0) |
| | | clearAuthSession() |
| | | clearJPushAliasBinding() |
| | | } |
| | | |
| New file |
| | |
| | | const TOKEN_KEY = 'token' |
| | | const USER_KEY = 'appUserInfo' |
| | | const TOKEN_EXPIRE_AT_KEY = 'tokenExpireAt' |
| | | const LOGOUT_MODAL_KEY = 'showLogoutModal' |
| | | const REDIRECT_META_KEY = 'loginRedirectMeta' |
| | | |
| | | /** |
| | | * 读取当前缓存中的 APP token。 |
| | | */ |
| | | export function getStoredToken() { |
| | | const token = uni.getStorageSync(TOKEN_KEY) |
| | | return typeof token === 'string' ? token.trim() : '' |
| | | } |
| | | |
| | | /** |
| | | * 判断当前是否存在本地登录态。 |
| | | */ |
| | | export function hasStoredSession() { |
| | | return !!getStoredToken() |
| | | } |
| | | |
| | | /** |
| | | * 读取当前缓存中的 APP 用户信息。 |
| | | */ |
| | | export function getStoredAppUser() { |
| | | const user = uni.getStorageSync(USER_KEY) |
| | | return user && typeof user === 'object' ? user : {} |
| | | } |
| | | |
| | | /** |
| | | * 判断当前缓存用户是否已完成手机号绑定。 |
| | | */ |
| | | export function hasBoundPhone() { |
| | | const user = getStoredAppUser() |
| | | return typeof user.phone === 'string' && user.phone.trim() !== '' |
| | | } |
| | | |
| | | /** |
| | | * 从登录响应中持久化 APP 登录态。 |
| | | */ |
| | | export function saveAuthSession(loginData, extras = {}) { |
| | | if (!loginData || typeof loginData !== 'object') { |
| | | return |
| | | } |
| | | 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.setStorageSync(TOKEN_KEY, `${tokenType} ${tokenValue}`.trim()) |
| | | } |
| | | const userInfo = loginData.user && typeof loginData.user === 'object' ? loginData.user : {} |
| | | uni.setStorageSync(USER_KEY, { |
| | | ...userInfo, |
| | | ...extras.userOverrides, |
| | | }) |
| | | uni.setStorageSync(LOGOUT_MODAL_KEY, 1) |
| | | persistTokenExpireAt(loginData.expiresIn) |
| | | if (extras.redirectMeta) { |
| | | uni.setStorageSync(REDIRECT_META_KEY, extras.redirectMeta) |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 根据 expiresIn 持久化 token 预计过期时间戳。 |
| | | */ |
| | | export function persistTokenExpireAt(expiresIn) { |
| | | const expireSeconds = Number(expiresIn) |
| | | if (!Number.isFinite(expireSeconds) || expireSeconds <= 0) { |
| | | uni.removeStorageSync(TOKEN_EXPIRE_AT_KEY) |
| | | return |
| | | } |
| | | uni.setStorageSync(TOKEN_EXPIRE_AT_KEY, Date.now() + expireSeconds * 1000) |
| | | } |
| | | |
| | | /** |
| | | * 读取当前 token 过期时间戳。 |
| | | */ |
| | | export function getTokenExpireAt() { |
| | | const rawValue = Number(uni.getStorageSync(TOKEN_EXPIRE_AT_KEY) || 0) |
| | | return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 0 |
| | | } |
| | | |
| | | /** |
| | | * 判断当前 token 是否即将过期。 |
| | | */ |
| | | export function willTokenExpireWithin(bufferMs = 0) { |
| | | const expireAt = getTokenExpireAt() |
| | | if (!expireAt) { |
| | | return false |
| | | } |
| | | return expireAt - Date.now() <= bufferMs |
| | | } |
| | | |
| | | /** |
| | | * 清理本地 APP 登录态缓存。 |
| | | */ |
| | | export function clearAuthSession() { |
| | | uni.removeStorageSync(TOKEN_KEY) |
| | | uni.removeStorageSync(USER_KEY) |
| | | uni.removeStorageSync(TOKEN_EXPIRE_AT_KEY) |
| | | uni.removeStorageSync(REDIRECT_META_KEY) |
| | | uni.setStorageSync(LOGOUT_MODAL_KEY, 0) |
| | | } |
| | |
| | | const GET = 'GET'; |
| | | const POST = 'POST'; |
| | | const PUT = 'PUT'; |
| | | const FORM = 'FORM'; |
| | | const DELETE = 'DELETE'; |
| | | import config from '@/config/index.js' |
| | | import { clearJPushAliasBinding } from '@/utils/push.js' |
| | | const baseURL = config.BASE_URL; |
| | | import { |
| | | clearAuthSession, |
| | | getStoredToken, |
| | | hasBoundPhone, |
| | | saveAuthSession, |
| | | willTokenExpireWithin, |
| | | } from '@/utils/auth-session.js' |
| | | |
| | | const waitingList = [] //等待队列 |
| | | const excutingList = [] //执行队列 |
| | | const GET = 'GET' |
| | | const POST = 'POST' |
| | | const PUT = 'PUT' |
| | | const FORM = 'FORM' |
| | | const DELETE = 'DELETE' |
| | | const baseURL = config.BASE_URL |
| | | const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000 |
| | | const SUCCESS_CODES = [0, 200] |
| | | const UNAUTHORIZED_CODES = [401, 504, 505, 510] |
| | | |
| | | const waitingList = [] |
| | | const excutingList = [] |
| | | let appAuthRedirecting = false |
| | | let refreshTokenPromise = null |
| | | |
| | | /** |
| | | * 判断当前响应码是否属于成功态。 |
| | | */ |
| | | function isSuccessCode(code) { |
| | | return code === 0 || code === 200 |
| | | return SUCCESS_CODES.includes(code) |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 读取当前语言请求头值。 |
| | | */ |
| | | function resolveLangHeader() { |
| | | return uni.getStorageSync('locale') === 'zh-Hans' ? 'zh_CN' : 'zh-tw' |
| | | } |
| | | |
| | | /** |
| | | * 构造当前请求头。 |
| | | */ |
| | | function buildHeaders(type) { |
| | | return { |
| | | 'content-type': type ? 'application/x-www-form-urlencoded;charset=UTF-8' : 'application/json', |
| | | Authorization: getStoredToken(), |
| | | lang: resolveLangHeader(), |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 统一展示普通业务错误提示。 |
| | | */ |
| | | function showErrorToast(payload) { |
| | | uni.showToast({ |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | mask: true, |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 统一发起请求,并兼容当前仓库 `code=0` 的后端成功协议。 |
| | | */ |
| | | function request(method, url, data, type, lodingFlag = true) { |
| | | if (lodingFlag) { |
| | | uni.showLoading({ |
| | | mask: true, |
| | | }) |
| | | } |
| | | return new Promise(function(resolve, reject) { |
| | | let token = uni.getStorageSync('token') |
| | | let header = { |
| | | 'content-type': type ? 'application/x-www-form-urlencoded;charset=UTF-8' : 'application/json', |
| | | 'Authorization': token, |
| | | 'lang': uni.getStorageSync('locale') === 'zh-Hans' ? 'zh_CN' : 'zh-tw', |
| | | } |
| | | const requestTask = uni.request({ |
| | | url: baseURL + url, |
| | | method: method, |
| | | data: data, |
| | | header: header, |
| | | success: (res) => { |
| | | if (lodingFlag) { |
| | | uni.hideLoading() |
| | | } |
| | | const payload = res.data || {} |
| | | if (isSuccessCode(payload.code)) { |
| | | resolve(payload); |
| | | return |
| | | } |
| | | if (payload.code == 502) { |
| | | resolve(payload); |
| | | uni.showToast({ |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | mask: true, |
| | | }) |
| | | return |
| | | } |
| | | if (payload.code == 1 || payload.code == 500) { |
| | | uni.showToast({ |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | mask: true, |
| | | }) |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (payload.code == 401 || payload.code == 510 || payload.code == 504 || payload.code == 505) { |
| | | handleUnauthorized(payload) |
| | | resolve(payload) |
| | | return |
| | | } |
| | | |
| | | if (payload.code == 501) { |
| | | uni.showToast({ |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | mask: true, |
| | | }) |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (payload.code == 506) { |
| | | resolve(payload); |
| | | return |
| | | } |
| | | |
| | | uni.showToast({ |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | return new Promise((resolve, reject) => { |
| | | const executeRequest = () => { |
| | | if (lodingFlag) { |
| | | uni.showLoading({ |
| | | mask: true, |
| | | }) |
| | | resolve(payload) |
| | | }, |
| | | fail(err) { |
| | | if (lodingFlag) { |
| | | uni.hideLoading() |
| | | } |
| | | reject(err) |
| | | }, |
| | | complete: () => { |
| | | const excutingIndex = excutingList.findIndex(item => item === requestTask) |
| | | excutingList.splice(excutingIndex, 1) |
| | | const [apiFn] = waitingList |
| | | if (excutingList.length === 0 && typeof apiFn === 'function') { |
| | | apiFn() |
| | | waitingList.splice(0, 1) |
| | | } |
| | | }, |
| | | }) |
| | | excutingList.push(requestTask) |
| | | } |
| | | const requestTask = uni.request({ |
| | | url: `${baseURL}${url}`, |
| | | method, |
| | | data, |
| | | header: buildHeaders(type), |
| | | success: (res) => { |
| | | if (lodingFlag) { |
| | | uni.hideLoading() |
| | | } |
| | | const payload = res.data || {} |
| | | if (isSuccessCode(payload.code)) { |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (payload.code === 502 || payload.code === 501) { |
| | | showErrorToast(payload) |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (payload.code === 1 || payload.code === 500) { |
| | | showErrorToast(payload) |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (UNAUTHORIZED_CODES.includes(payload.code)) { |
| | | handleUnauthorized(payload) |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (payload.code === 506) { |
| | | resolve(payload) |
| | | return |
| | | } |
| | | showErrorToast(payload) |
| | | resolve(payload) |
| | | }, |
| | | fail: (error) => { |
| | | if (lodingFlag) { |
| | | uni.hideLoading() |
| | | } |
| | | reject(error) |
| | | }, |
| | | complete: () => { |
| | | const excutingIndex = excutingList.findIndex((item) => item === requestTask) |
| | | if (excutingIndex >= 0) { |
| | | excutingList.splice(excutingIndex, 1) |
| | | } |
| | | const [apiFn] = waitingList |
| | | if (excutingList.length === 0 && typeof apiFn === 'function') { |
| | | apiFn() |
| | | waitingList.splice(0, 1) |
| | | } |
| | | }, |
| | | }) |
| | | excutingList.push(requestTask) |
| | | } |
| | | |
| | | ensureSessionFresh(url) |
| | | .then(() => { |
| | | executeRequest() |
| | | }) |
| | | .catch((error) => { |
| | | reject(error) |
| | | }) |
| | | }) |
| | | |
| | | |
| | | } |
| | | |
| | | /** |
| | |
| | | }) |
| | | setTimeout(() => { |
| | | uni.reLaunch({ |
| | | url: '/pages/login/login' |
| | | url: '/pages/login/login', |
| | | }) |
| | | setTimeout(() => { |
| | | appAuthRedirecting = false |
| | |
| | | * 清除 APP 登录态缓存。 |
| | | */ |
| | | function clearLoginSession() { |
| | | uni.removeStorageSync('token') |
| | | uni.removeStorageSync('appUserInfo') |
| | | uni.removeStorageSync('loginRedirectMeta') |
| | | uni.setStorageSync('showLogoutModal', 0) |
| | | clearAuthSession() |
| | | clearJPushAliasBinding() |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 清除请求队列。 |
| | | * 清除请求队列,避免登录失效后旧请求继续回写界面状态。 |
| | | */ |
| | | function clearAllRequest() { |
| | | if (excutingList.length > 0) { |
| | | for (var i = 0; i < excutingList.length; i++) { |
| | | const item = excutingList[i] |
| | | if (item && item.abort) { |
| | | item.abort() |
| | | } |
| | | excutingList.forEach((item) => { |
| | | if (item && typeof item.abort === 'function') { |
| | | item.abort() |
| | | } |
| | | excutingList.length = 0 |
| | | waitingList.length = 0 |
| | | }) |
| | | excutingList.length = 0 |
| | | waitingList.length = 0 |
| | | } |
| | | |
| | | /** |
| | | * 在请求发送前按阈值串行刷新 APP token,避免多个接口同时触发续期。 |
| | | */ |
| | | function ensureSessionFresh(url) { |
| | | if (!shouldAttemptTokenRefresh(url)) { |
| | | return Promise.resolve() |
| | | } |
| | | if (refreshTokenPromise) { |
| | | return refreshTokenPromise |
| | | } |
| | | refreshTokenPromise = refreshAppToken().finally(() => { |
| | | refreshTokenPromise = null |
| | | }) |
| | | return refreshTokenPromise |
| | | } |
| | | |
| | | /** |
| | | * 判断当前请求是否需要在发送前尝试刷新 token。 |
| | | */ |
| | | function shouldAttemptTokenRefresh(url) { |
| | | const token = getStoredToken() |
| | | if (!token || !hasBoundPhone()) { |
| | | return false |
| | | } |
| | | if (url === '/api/app/auth/refresh-token' || url === '/api/v1/app/auth/refresh-token') { |
| | | return false |
| | | } |
| | | const routeStr = getCurrentRoute() |
| | | if (routeStr === 'pages/login/login' || routeStr === 'pages/register/register' || routeStr === 'pages/bindPhone/bindPhone') { |
| | | return false |
| | | } |
| | | return willTokenExpireWithin(TOKEN_REFRESH_BUFFER_MS) |
| | | } |
| | | |
| | | /** |
| | | * 调用后端续期接口并回写本地 token 与用户信息。 |
| | | */ |
| | | function refreshAppToken() { |
| | | return new Promise((resolve, reject) => { |
| | | uni.request({ |
| | | url: `${baseURL}/api/app/auth/refresh-token`, |
| | | method: POST, |
| | | data: {}, |
| | | header: { |
| | | 'content-type': 'application/json', |
| | | Authorization: getStoredToken(), |
| | | lang: resolveLangHeader(), |
| | | }, |
| | | success: (res) => { |
| | | const payload = res.data || {} |
| | | if (isSuccessCode(payload.code) && payload.data) { |
| | | saveAuthSession(payload.data) |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (UNAUTHORIZED_CODES.includes(payload.code)) { |
| | | handleUnauthorized(payload) |
| | | } |
| | | reject(payload) |
| | | }, |
| | | fail: (error) => { |
| | | reject(error) |
| | | }, |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | /** |