authRedirect.js 12.7 KB
import Taro from '@tarojs/taro'
import { routerStore } from '@/stores/router'
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config'

/**
 * 授权与回跳相关工具
 * - 统一管理:保存来源页、静默授权、跳转授权页、授权后回跳
 * - 约定:sessionid 存在于本地缓存 key 为 sessionid
 * - 说明:refreshSession/silentAuth 使用单例 Promise,避免并发重复授权
 */

/**
 * 获取当前页完整路径(含 query)
 * @returns {string} 当前页路径,示例:pages/index/index?a=1;获取失败返回空字符串
 */
export const getCurrentPageFullPath = () => {
    const pages = Taro.getCurrentPages()
    if (!pages || pages.length === 0) return ''

    const current_page = pages[pages.length - 1]
    const route = current_page.route
    const options = current_page.options || {}

    const query_params = Object.keys(options)
        .map((key) => `${key}=${encodeURIComponent(options[key])}`)
        .join('&')

    return query_params ? `${route}?${query_params}` : route
}

/**
 * 保存当前页路径(用于授权成功后回跳)
 * @param {string} custom_path 自定义路径,不传则取当前页完整路径
 * @returns {void} 无返回值
 */
export const saveCurrentPagePath = (custom_path) => {
    const router = routerStore()
    const path = custom_path || getCurrentPageFullPath()
    router.add(path)
}

/**
 * 判断是否已授权
 * @returns {boolean} true=已存在 sessionid,false=需要授权
 */
export const hasAuth = () => {
    try {
        const sessionid = Taro.getStorageSync('sessionid')
        return !!sessionid && sessionid !== ''
    } catch (error) {
        console.error('检查授权状态失败:', error)
        return false
    }
}

let auth_promise = null

/**
 * 刷新会话:通过 Taro.login 获取 code,换取后端会话 cookie 并写入缓存
 * - 被 request.js 的 401 拦截器调用,用于自动“静默续期 + 原请求重放”
 * - 复用 auth_promise,防止多个接口同时 401 时并发触发多次登录
 * @param {object} options 可选项
 * @param {boolean} options.show_loading 是否展示 loading,默认 true
 * @returns {Promise<{code:number,msg?:string,data?:any,cookie?:string}>} 授权结果(会把 cookie 写入 storage 的 sessionid)
 */
export const refreshSession = async (options) => {
    const show_loading = options?.show_loading !== false

    // 已有授权进行中时,直接复用同一个 Promise
    if (auth_promise) return auth_promise

    auth_promise = (async () => {
        try {
            if (show_loading) {
                Taro.showLoading({
                    title: '加载中...',
                    mask: true,
                })
            }

            // 调用微信登录获取临时 code
            const login_result = await new Promise((resolve, reject) => {
                Taro.login({
                    success: resolve,
                    fail: reject,
                })
            })

            if (!login_result || !login_result.code) {
                throw new Error('获取微信登录code失败')
            }

            const request_data = {
                code: login_result.code,
            }

            // 开发环境可按需手动传 openid(仅用于本地联调)
            if (process.env.NODE_ENV === 'development') {
                // request_data.openid = 'waj';
                // request_data.openid = 'h-009';
                // request_data.openid = 'h-010';
                // request_data.openid = 'h-011';
                // request_data.openid = 'h-012';
                // request_data.openid = 'h-013';
                // request_data.openid = 'oWbdFvkD5VtloC50wSNR9IWiU2q8';
                // request_data.openid = 'oex8h5QZnZJto3ttvO6swSvylAQo';
            }

            if (process.env.NODE_ENV === 'production') {
                // request_data.openid = 'h-013';
            }

            // 换取后端会话(服务端通过 Set-Cookie 返回会话信息)
            const response = await Taro.request({
                url: `${BASE_URL}/srv/?a=openid_wxapp&f=${encodeURIComponent(REQUEST_DEFAULT_PARAMS.f)}&client_name=${encodeURIComponent(REQUEST_DEFAULT_PARAMS.client_name)}`,
                method: 'POST',
                data: request_data,
            })

            if (!response?.data || response.data.code !== 1) {
                throw new Error(response?.data?.msg || '授权失败')
            }

            // 兼容小程序环境下 cookie 的不同字段位置
            let cookie =
                (response.cookies && response.cookies[0]) ||
                response.header?.['Set-Cookie'] ||
                response.header?.['set-cookie']

            if (Array.isArray(cookie)) cookie = cookie[0]

            if (!cookie) {
                throw new Error('授权失败:没有获取到有效的会话信息')
            }

            // NOTE: 写入本地缓存:后续请求会从缓存取 sessionid 并带到请求头
            Taro.setStorageSync('sessionid', cookie)

            /**
             * refreshSession() 的返回值当前没有任何业务消费点:在 request.js 里只是 await refreshSession() ,不解构、不使用;其他地方也没直接调用它
             * 所以 return { ...response.data, cookie } 目前属于“严谨保留”:方便未来需要拿 cookie / code / msg 做埋点、提示、分支处理时直接用(例如授权页显示更细错误、统计刷新成功率等)。
             */

            return {
                ...response.data,
                cookie,
            }
        } finally {
            if (show_loading) {
                Taro.hideLoading()
            }
        }
    })().finally(() => {
        auth_promise = null
    })

    return auth_promise
}

/**
 * 执行静默授权:检查是否已授权,若否则调用 refreshSession 刷新会话
 * @param {boolean} show_loading 是否展示 loading,默认 true
 * @returns {Promise<{code:number,msg?:string,data?:any,cookie?:string}>} 授权结果
 */
const do_silent_auth = async (show_loading) => {
    // 已有 sessionid 时直接视为已授权
    if (hasAuth()) {
        return { code: 1, msg: '已授权' }
    }

    // 需要授权时,走刷新会话逻辑
    return await refreshSession({ show_loading })
}

/**
 * 静默授权:用于启动阶段/分享页/授权页发起授权
 * - 与 refreshSession 共用 auth_promise,避免并发重复调用
 * @param {(result: any) => void} on_success 成功回调(可选)
 * @param {(error_msg: string) => void} on_error 失败回调(可选,入参为错误文案)
 * @param {object} options 可选项
 * @param {boolean} options.show_loading 是否展示 loading,默认 true
 * @returns {Promise<any>} 授权结果(成功 resolve,失败 reject)
 */
export const silentAuth = async (on_success, on_error, options) => {
    const show_loading = options?.show_loading !== false

    try {
        // 未有授权进行中时才发起一次授权,并复用 Promise
        if (!auth_promise) {
            /**
             * 用 auth_promise 做“单例锁”,把同一时刻并发触发的多次授权合并成一次。
             * 把正在执行的授权 Promise 存起来;后面如果又有人调用 silentAuth() ,
             * 看到 auth_promise 不为空,就直接 await 同一个 Promise,避免同时发起多次 Taro.login / 换会话请求
             * ---------------------------------------------------------------------------------------
             * .finally(() => { auth_promise = null }) 不管授权成功还是失败(resolve/reject),都把“锁”释放掉。
             * 不用 finally 的问题:如果授权失败抛错了,而你只在 .then 里清空,那么 auth_promise 会一直卡着旧的 rejected Promise;
             * 后续再调用 silentAuth() 会复用这个失败的 Promise,导致永远失败、且永远不会重新发起授权。
             * 用 finally :保证成功/失败都会清空,下一次调用才有机会重新走授权流程。
             */
            auth_promise = do_silent_auth(show_loading)
                            .finally(() => {
                                auth_promise = null
                            })
        }
        const result = await auth_promise
        if (on_success) on_success(result)

        /**
         * 当前返回值 没有实际消费点 :全项目只在 3 处调用,全部都 不使用返回值 。
         * - 启动预加载: await silentAuth() 仅等待,不用结果, app.js
         * - 授权页: silentAuth().then(() => returnToOriginalPage()) then 里也没接 res , auth/index.vue
         * - 分享场景: await silentAuth(successCb, errorCb) 只看成功/失败分支,不用返回值, handleSharePageAuth
         * 所以这行 return result 的作用目前是 语义完整 + 未来扩展位 :
         * 如果以后要在调用处根据 code/msg/cookie 做分支或埋点,返回值就能直接用;现在等价于“只用 resolve/reject 表达成功失败”。
         */

        return result
    } catch (error) {
        const error_msg = error?.message || '授权失败,请稍后重试'
        if (on_error) on_error(error_msg)
        throw error
    }
}

/**
 * 跳转到授权页(降级方案)
 * - 会先保存回跳路径(默认当前页),授权成功后在 auth 页回跳
 * @param {string} return_path 指定回跳路径(可选)
 * @returns {void} 无返回值
 */
export const navigateToAuth = (return_path) => {
    if (return_path) {
        saveCurrentPagePath(return_path)
    } else {
        saveCurrentPagePath()
    }

    const pages = Taro.getCurrentPages()
    const current_page = pages[pages.length - 1]
    const current_route = current_page?.route
    if (current_route === 'pages/auth/index') {
        return
    }

    Taro.navigateTo({ url: '/pages/auth/index' }).catch(() => {
        return Taro.redirectTo({ url: '/pages/auth/index' })
    })
}

/**
 * 授权成功后回跳到来源页
 * - 优先使用 routerStore 里保存的路径
 * - 失败降级:redirectTo -> reLaunch
 * @param {string} default_path 未保存来源页时的默认回跳路径
 * @returns {Promise<void>} 回跳完成
 */
export const returnToOriginalPage = async (default_path = '/pages/index/index') => {
    const router = routerStore()
    const saved_path = router.url

    try {
        router.remove()

        const pages = Taro.getCurrentPages()
        const current_page = pages[pages.length - 1]
        const current_route = current_page?.route

        let target_path = default_path
        if (saved_path && saved_path !== '') {
            target_path = saved_path.startsWith('/') ? saved_path : `/${saved_path}`
        }

        const target_route = target_path.split('?')[0].replace(/^\//, '')

        if (current_route === target_route) {
            return
        }

        try {
            await Taro.redirectTo({ url: target_path })
        } catch (error) {
            await Taro.reLaunch({ url: target_path })
        }
    } catch (error) {
        console.error('returnToOriginalPage 执行出错:', error)
        try {
            await Taro.reLaunch({ url: default_path })
        } catch (final_error) {
            console.error('最终降级方案也失败了:', final_error)
        }
    }
}

/**
 * 判断是否来自分享场景
 * @param {object} options 页面 options
 * @returns {boolean} true=来自分享场景,false=非分享场景
 */
export const isFromShare = (options) => {
    return options && (options.from_share === '1' || options.scene)
}

/**
 * 分享页进入时的授权处理
 * - 来自分享且未授权:保存当前页路径,授权成功后回跳
 * - 授权失败:返回 false,由调用方决定是否继续降级处理
 * @param {object} options 页面 options
 * @param {Function} callback 授权成功后的继续逻辑(可选)
 * @returns {Promise<boolean>} true=授权已完成/无需授权,false=授权失败
 */
export const handleSharePageAuth = async (options, callback) => {
    if (hasAuth()) {
        if (typeof callback === 'function') callback()
        return true
    }

    if (isFromShare(options)) {
        saveCurrentPagePath()
    }

    try {
        await silentAuth(
            () => {
                if (typeof callback === 'function') callback()
            },
            () => {
                Taro.navigateTo({ url: '/pages/auth/index' })
            }
        )
        return true
    } catch (error) {
        Taro.navigateTo({ url: '/pages/auth/index' })
        return false
    }
}

/**
 * 为路径追加分享标记
 * @param {string} path 原路径
 * @returns {string} 追加后的路径
 */
export const addShareFlag = (path) => {
    const separator = path.includes('?') ? '&' : '?'
    return `${path}${separator}from_share=1`
}