个人开发者接入阿里云号码认证服务AliCloud-NirvanaPns实现一键登录

手机号一键登录功能全面分析

一、系统架构总览

该功能基于**阿里云号码认证服务(PNVS)**实现一键登录,采用典型的客户端-服务端架构,涉及三个主要层面:

二、核心模块分析

2.1 前端核心模块 2.1.1 SDK 封装模块( one-click-login.js )

该模块承担与原生插件的交互封装,包含四个核心函数:

SDK 状态码定义 :

js 复制代码
const CODES = {
    SUCCESS: '600000',      // 操作成功
    PAGE_READY: '600001',   // 授权页拉起成功
    CANCEL: '700000',       // 用户取消/返回
    CLICK_LOGIN: '700002',  // 点击登录按钮
    CLICK_AGREEMENT: '700004' // 点击协议链接
};
js 复制代码
// utils/one-click-login.js --- 阿里云号码认证一键登录
// 参考: https://help.aliyun.com/zh/pnvs/developer-reference/uni-app-access
//
// 平台支持: Android / iOS 原生 App(需要集成原生插件)
// H5 / 小程序不支持(需降级为短信验证码登录)

import { getDeviceInfo, ensureIpInfo } from './device';
import { userApi } from '../apis';

/**
 * 一键登录状态码
 * 600000 = 成功
 * 600001 = 授权页拉起成功
 * 700000 = 用户取消/返回
 * 700002 = 点击登录按钮
 * 700004 = 点击协议
 */
const CODES = {
    SUCCESS: '600000',
    PAGE_READY: '600001',
    CANCEL: '700000',
    CLICK_LOGIN: '700002',
    CLICK_AGREEMENT: '700004'
};

/** 检测是否在 App 原生环境 */
function isAppEnv() {
    // #ifdef APP-PLUS
    return true;
    // #endif
    // #ifdef H5
    return false;
    // #endif
    // #ifdef MP-WEIXIN
    return false;
    // #endif
    return !!uni.requireNativePlugin;
}

let _sdkModule = null; // 原生插件实例
let _initialized = false; // 是否已初始化
let _isLoginPageShowing = false; // 授权页是否正在显示
let _lastPreLoginTime = 0;      // 上次预取号成功的时间戳
let _isPreLoginRunning = false; // 预取号是否正在执行中
const PRELOGIN_COOLDOWN_MS = 60000; // 预取号凭证有效约60秒,此期间不重复调用

/**
 * 初始化 SDK(必须在使用前调用一次)
 * @param {string} sdkInfo - 阿里云控制台生成的密钥字符串 (SDK Info)
 * @returns {{ success: boolean, msg: string }}
 */
export function init(sdkInfo) {
    if (!isAppEnv()) {
        console.warn('[OneClickLogin] 当前环境不支持一键登录,仅 App 原生可用');
        return { success: false, msg: '仅支持 App 原生环境' };
    }

    if (!sdkInfo) {
        console.error('[OneClickLogin] 初始化失败:未提供 SDK 密钥');
        return { success: false, msg: '未配置 SDK 密钥' };
    }

    try {
        _sdkModule = uni.requireNativePlugin('AliCloud-NirvanaPns');

        if (!_sdkModule) {
            console.error('[OneClickLogin] 原生插件未安装或未正确引入');
            return { success: false, msg: '原生插件未安装' };
        }

        console.log('[OneClickLogin] 插件实例:', typeof _sdkModule, Object.keys(_sdkModule || {}));

        _sdkModule.setAuthSDKInfo(sdkInfo);
        _initialized = true;
        console.log('[OneClickLogin] SDK 初始化成功');
        return { success: true, msg: '初始化成功' };
    } catch (err) {
        console.error('[OneClickLogin] 初始化异常:', err);
        return { success: false, msg: `初始化异常: ${err.message || err}` };
    }
}

/**
 * 检查当前网络环境是否支持一键登录
 * @returns {Promise<{ supported: boolean, msg?: string }>}
 */
export function checkEnv() {
    return new Promise((resolve) => {
        if (!_sdkModule || !_initialized) {
            resolve({ supported: false, msg: 'SDK 未初始化' });
            return;
        }

        try {
            _sdkModule.checkEnvAvailable(
                2, // type=2 表示检查一键登录
                (result) => {
                    const code = String(result.resultCode || '');
                    if (code === CODES.SUCCESS) {
                        resolve({ supported: true });
                    } else {
                        resolve({
                            supported: false,
                            msg: result.msg || `环境不支持 (code=${code})`
                        });
                    }
                }
            );
        } catch (err) {
            resolve({ supported: false, msg: `检测异常: ${err.message}` });
        }
    });
}

/**
 * 预取号(加速唤起授权页)
 * 封装原生 aLiSDKModule.accelerateLoginPage()
 *
 * 使用规则(来源:阿里云文档):
 *   - 仅未登录用户使用,已登录用户请勿调用
 *   - 建议在唤起授权页前 2~3 秒调用(需要 1~3 秒获取临时凭证)
 *   - 请勿频繁多次调用(临时凭证有效约 60 秒)
 *   - 请勿与 getLoginToken 同时或之后调用
 *   - 打开 App 就自动登录的场景不需要调用
 *
 * @param {number} timeout - 超时时间(ms),默认 5000
 * @returns {Promise<{ success: boolean, msg?: string }>}
 */
export function preLogin(timeout = 5000) {
    return new Promise((resolve) => {
        // ----- 保护性检查 -----
        if (!_sdkModule || !_initialized) {
            resolve({ success: false, msg: 'SDK 未初始化' });
            return;
        }

        // 授权页已显示时禁止调用(与 getLoginToken 冲突)
        if (_isLoginPageShowing) {
            resolve({ success: false, msg: '授权页已显示,禁止同时调用 preLogin' });
            return;
        }

        // 冷却期内不重复调用
        const now = Date.now();
        if (now - _lastPreLoginTime < PRELOGIN_COOLDOWN_MS && _lastPreLoginTime > 0) {
            resolve({ success: true, msg: '预取号凭证仍在有效期内,跳过' });
            return;
        }

        // 避免并发
        if (_isPreLoginRunning) {
            resolve({ success: false, msg: '预取号正在执行中' });
            return;
        }

        // ----- 执行预取号 -----
        _isPreLoginRunning = true;
        try {
            _sdkModule.accelerateLoginPage(timeout, (res) => {
                _isPreLoginRunning = false;
                const code = String(res.resultCode || '');
                if (code === CODES.SUCCESS) {
                    _lastPreLoginTime = Date.now();
                    console.log('[OneClickLogin] 预取号加速成功');
                    resolve({ success: true });
                } else {
                    console.warn(`[OneClickLogin] 预取号失败: code=${code}, msg=${res.msg}`);
                    resolve({
                        success: false,
                        msg: res.msg || `预取号失败 (code=${code})`
                    });
                }
            });
        } catch (err) {
            _isPreLoginRunning = false;
            resolve({ success: false, msg: `预取号异常: ${err.message}` });
        }
    });
}

/**
 * 检查预取号凭证是否仍在有效期内
 * @returns {boolean}
 */
export function isPreLoginValid() {
    if (_lastPreLoginTime === 0) return false;
    return Date.now() - _lastPreLoginTime < PRELOGIN_COOLDOWN_MS;
}

/**
 * 获取上次预取号成功以来的时间差(ms),便于调用方判断是否需要重新预取
 * @returns {number} -1 表示从未预取过
 */
export function getPreLoginAge() {
    return _lastPreLoginTime === 0 ? -1 : Date.now() - _lastPreLoginTime;
}

/**
 * 关闭授权页面(必须在获取 Token 成功或用户取消后调用)
 */
export function quitLoginPage() {
    try {
        if (_sdkModule && _isLoginPageShowing) {
            _sdkModule.quitLoginPage();
            _isLoginPageShowing = false;
        }
    } catch {
        /* noop */
    }
}

/**
 * 执行一键登录 --- 完整流程:唤起授权 → 获取Token → 调后端接口 → 返回登录态
 *
 * @param {Object} [options]
 * @param {number} [options.timeout=5000] - 授权页超时时间(ms)
 * @param {Object} [options.uiConfig] - 授权页 UI 配置(参考阿里云文档)
 * @returns {Promise<{
 *   success: boolean,
 *   data?: { access_token, user_id, username, is_new_user?, phone_masked? },
 *   msg?: string,
 *   cancelled?: boolean
 * }>}
 */
export async function login(options = {}) {
    const { timeout = 5000, uiConfig = {} } = options;

    console.log('[OneClickLogin] login() 开始, timeout=' + timeout);

    // 环境检查
    if (!isAppEnv()) {
        return { success: false, msg: '一键登录仅支持 App 原生环境', cancelled: false };
    }
    if (!_sdkModule || !_initialized) {
        return { success: false, msg: 'SDK 未初始化', cancelled: false };
    }

    // 确保IP信息已获取(带超时保护)
    try {
        console.log('[OneClickLogin] 开始 ensureIpInfo...');
        await Promise.race([ensureIpInfo(), new Promise((_, reject) => setTimeout(() => reject(new Error('IP信息获取超时')), 8000))]);
        console.log('[OneClickLogin] ensureIpInfo 完成');
    } catch (err) {
        console.warn('[OneClickLogin] ensureIpInfo 失败,继续登录流程:', err.message);
    }

    // ===== 等待 accelerateLoginPage 缓冲 =====
    // 阿里云文档建议:accelerateLoginPage 成功后,等待 2~3 秒再调用 getLoginToken
    // 原因:加速方法需要 1~3 秒取得临时凭证,调用过早可能凭证未就绪
    if (isPreLoginValid()) {
        const age = getPreLoginAge();
        const minBufferMs = 2000; // 最小缓冲 2 秒
        if (age < minBufferMs) {
            const waitMs = minBufferMs - age;
            console.log('[OneClickLogin] accelerateLoginPage 缓冲等待 ' + waitMs + 'ms...');
            await new Promise((r) => setTimeout(r, waitMs));
        }
    } else {
        // 凭证已过期或从未预取,先重新预取再等待
        console.log('[OneClickLogin] 预取号凭证无效,重新预取...');
        const preResult = await preLogin(5000);
        if (!preResult.success) {
            console.warn('[OneClickLogin] 预取号失败,仍尝试登录:', preResult.msg);
        } else {
            console.log('[OneClickLogin] 重新预取成功,等待 2 秒缓冲...');
            await new Promise((r) => setTimeout(r, 2500));
        }
    }

    // ===== 构造 getLoginToken 所需的 config 对象 =====
    // 根据 DCloud 官方文档,第二个参数格式为:
    // { uiConfig: { /* UI配置 */ }, widgets: [ /* 自定义控件 */ ] }
    // 直接将 {} 作为第二个参数会导致原生插件无法解析而静默失败。
    const sdkConfig = {
        uiConfig: {
            // 默认隐藏"切换其他登录方式"按钮(本应用有自己的 tab 切换)
            setSwitchHidden: 'true',
            // 显示协议勾选框
            setCheckboxHidden: 'false',
            // 协议文案(必须配置,否则审核不通过)
            setPrivacyUi: {
                beforeText: '我已阅读并同意',
                endText: '并使用本机号码登录',
                baseColor: '#999999',
                protocolColor: '#1677FF',
                textSize: '12',
                bottom: '20',
                marginLR: '18',
                alignment: '1'
            },
            setAppPrivacyOne: {
                title: '《用户服务协议》',
                url: 'https://www.example.com/protocol/user'
            },
            setAppPrivacyTwo: {
                title: '《隐私政策》',
                url: 'https://www.example.com/protocol/privacy'
            },
            ...uiConfig // 允许调用方覆盖默认配置
        },
        widgets: options.widgets || []
    };

    console.log('[OneClickLogin] 预取号凭证状态:', {
        valid: isPreLoginValid(),
        age: getPreLoginAge() + 'ms'
    });

    return new Promise((resolve) => {
        let resolved = false;

        /** 标记已解决并防止重复调用 */
        const done = (result) => {
            if (resolved) return;
            resolved = true;
            quitLoginPage(); // 无论结果如何都关闭授权页
            console.log('[OneClickLogin] login() 结果:', result);
            resolve(result);
        };

        // ===== 超时兜底:防止 SDK 回调丢失导致 Promise 永久 pending =====
        const safetyTimer = setTimeout(() => {
            console.warn('[OneClickLogin] login() 超时(' + (timeout + 5000) + 'ms),SDK 无响应');
            done({
                success: false,
                msg: '一键登录超时,请检查网络后重试,或切换验证码登录',
                cancelled: false
            });
        }, timeout + 5000); // 比 SDK timeout 多给5秒余量

        try {
            console.log('[OneClickLogin] 调用 getLoginToken(), timeout=' + timeout);
            _sdkModule.getLoginToken(
                timeout,
                sdkConfig,

                // ===== Token 回调 =====
                (tokenResult) => {
                    const code = String(tokenResult?.resultCode || '');
                    console.log('[OneClickLogin] Token 回调触发, code=' + code);

                    switch (code) {
                        case CODES.PAGE_READY:
                            _isLoginPageShowing = true;
                            console.log('[OneClickLogin] 授权页已拉起');
                            break;

                        case CODES.SUCCESS:
                            _isLoginPageShowing = true;
                            console.log('[OneClickLogin] 获取 Token 成功');
                            clearTimeout(safetyTimer);
                            const token = tokenResult.token;

                            // 将 token 发送到后端验证并换取登录态
                            _doServerLogin(token)
                                .then((serverRes) => done(serverRes))
                                .catch((err) =>
                                    done({
                                        success: false,
                                        msg: err.msg || '后端验证失败'
                                    })
                                );
                            break;

                        default:
                            clearTimeout(safetyTimer);
                            const errMsg = tokenResult?.msg || `未知错误 (code=${code})`;
                            console.warn(`[OneClickLogin] 获取 Token 失败: ${errMsg}`);
                            done({
                                success: false,
                                msg: errMsg,
                                cancelled: false
                            });
                            break;
                    }
                },

                // ===== 点击事件回调 =====
                (clickResult) => {
                    const clickCode = String(clickResult?.resultCode || '');
                    console.log('[OneClickLogin] Click 回调触发, code=' + clickCode);

                    switch (clickCode) {
                        case CODES.CANCEL:
                            console.log('[OneClickLogin] 用户取消了授权');
                            clearTimeout(safetyTimer);
                            done({
                                success: false,
                                msg: '用户取消',
                                cancelled: true
                            });
                            break;

                        case CODES.CLICK_LOGIN:
                            // 可以在这里做 checkbox 检查等
                            if (clickResult?.isChecked === false) {
                                uni.showToast({ title: '请先同意协议', icon: 'none' });
                            }
                            break;

                        case CODES.CLICK_AGREEMENT:
                            console.log('[OneClickLogin] 用户点击了协议链接');
                            break;

                        default:
                            break;
                    }
                },

                // 自定义控件回调
                () => {}
            );
        } catch (err) {
            clearTimeout(safetyTimer);
            console.error('[OneClickLogin] getLoginToken 调用异常:', err);
            done({
                success: false,
                msg: `唤起异常: ${err.message || err}`,
                cancelled: false
            });
        }
    });
}

/**
 * 调用后端一键登录接口,用阿里云 Token 换取 JWT 登录态
 * @param {string} token - 阿里云 getLoginToken 返回的 token
 * @returns {Promise<*>}
 */
async function _doServerLogin(token) {
    const deviceInfo = getDeviceInfo();

    const res = await userApi.oneClickLogin({
        token: token,
        device_id: deviceInfo.device_id,
        device_name: deviceInfo.device_name,
        device_type: deviceInfo.device_type,
        public_ip: deviceInfo.public_ip,
        login_address: deviceInfo.login_address
    });

    if (res && res.access_token) {
        console.log(`[OneClickLogin] 后端登录成功: user_id=${res.user_id}`);
        return {
            success: true,
            data: res
        };
    }

    return {
        success: false,
        msg: res?.msg || '登录失败'
    };
}

/**
 * 获取当前是否已初始化
 */
export function isReady() {
    return _initialized && !!_sdkModule;
}

/**
 * 获取当前是否在原生 App 环境
 */
export { isAppEnv };

// 导出状态码常量供外部使用
export { CODES };

2.1.2 设备信息模块( device.js )

提供跨平台设备标识和网络信息:

2.1.3 登录页面( login.vue )

页面生命周期中与一键登录相关的关键流程:

js 复制代码
onMounted() 
    └── initOneClickLogin()
            ├── userApi.getOneClickSdkInfo()     → 获取SDK密钥
            ├── oneClickLogin.init(sdkInfo)       → 初始化插件
            ├── oneClickLogin.checkEnv()          → 环境检测
            └── oneClickLogin.preLogin(5000)     → 预取号

用户点击按钮
    └── handleOneClickLogin()
            ├── 检查预取号凭证有效性
            ├── oneClickLogin.login({timeout:8000})
            │       ├── getLoginToken()           → 唤起授权页
            │       ├── _doServerLogin(token)      → 后端验证
            │       └── 返回 JWT + 用户信息
            └── 存储 token → 跳转首页
js 复制代码
/** 初始化一键登录 SDK */
const initOneClickLogin = async () => {
    // #ifdef APP-PLUS
    try {
        // 从后端API动态获取SDK密钥,避免硬编码泄露
        const sdkRes = await userApi.getOneClickSdkInfo();
        if (!sdkRes || !sdkRes.available || !sdkRes.sdk_info) {
            console.log('[Login] 一键登录SDK密钥未配置或获取失败');
            return;
        }

        const initResult = oneClickLogin.init(sdkRes.sdk_info);
        if (initResult.success) {
            oneClickAvailable.value = true;

            // 检查环境支持
            const envResult = await oneClickLogin.checkEnv();
            isOneClickReady.value = envResult.supported;
            if (envResult.supported) {
                // 预取号:加速后续授权页唤起(仅在未登录状态下使用)
                // 阿里云文档建议:提前 2~3 秒调用,获取临时凭证约需 1~3 秒
                // SDK 内部自带冷却,不会重复无效调用
                await oneClickLogin.preLogin(5000);
            } else {
                console.log('[Login] 一键登录环境不支持:', envResult.msg);
            }
        } else {
            console.log('[Login] 一键登录初始化失败:', initResult.msg);
        }
    } catch (err) {
        console.error('[Login] 一键登录初始化异常:', err);
    }
    // #endif
};

/** 执行一键登录 */
const handleOneClickLogin = async () => {
    if (isOneClickLoading.value || !isOneClickReady.value) return;

    try {
        isOneClickLoading.value = true;

        // 检查预取号凭证是否仍在有效期内,过期则重新预取(带 ~2-3s 缓冲)
        if (!oneClickLogin.isPreLoginValid()) {
            console.log('[Login] 预取号凭证已过期,重新预取中...');
            const preResult = await oneClickLogin.preLogin(5000);
            if (!preResult.success) {
                console.warn('[Login] 重新预取号失败,继续尝试登录:', preResult.msg);
            }
        }

        const result = await oneClickLogin.login({ timeout: 8000 });

        if (result.cancelled) {
            uni.showToast({ title: '已取消', icon: 'none' });
            return;
        }

        if (result.success && result.data?.access_token) {
            userStore.token = result.data.access_token;
            userStore.userInfo = {
                id: result.data.user_id,
                username: result.data.username
            };
            setToken(result.data.access_token);
            setUserInfo(userStore.userInfo);

            await userStore.fetchUserInfo();

            const msg = result.data.is_new_user ? '新账号已自动创建并登录' : '登录成功';
            uni.showToast({ title: msg, icon: 'success' });

            sseClient.reconnect();
            setTimeout(() => {
                uni.switchTab({ url: '/pages/index/index' });
            }, 1500);
        } else {
            // 超时场景:提示降级方案
            const isTimeout = result.msg && result.msg.includes('超时');
            if (isTimeout) {
                uni.showModal({
                    title: '一键登录超时',
                    content: '当前运营商网络环境不支持一键登录,建议切换验证码登录',
                    confirmText: '切换验证码登录',
                    cancelText: '取消',
                    success: (res) => {
                        if (res.confirm) {
                            switchTab('sms');
                        }
                    }
                });
            } else {
                uni.showToast({
                    title: result.msg || '一键登录失败',
                    icon: 'none',
                    duration: 2000
                });
            }
        }
    } catch (error) {
        uni.showToast({ title: error.msg || '一键登录异常', icon: 'none', duration: 2000 });
    } finally {
        isOneClickLoading.value = false;
    }
};

// 页面加载时尝试初始化一键登录
onMounted(() => {
    initOneClickLogin();
});

2.2 后端核心模块

2.2.1 路由处理( auth.py

后端提供三个 REST 接口:

py 复制代码
@auth_bp.route('/one-click-login', methods=['POST'])
def one_click_login():
    """
    阿里云号码认证一键登录。
    
    流程:
      1. 前端通过原生SDK调用 getLoginToken() 获取 token
      2. 将 token 发送到此接口
      3. 后端调用阿里云 GetMobile 接口验证 token 并获取真实手机号
      4. 根据手机号查询或自动注册用户
      5. 返回 JWT 登录态
    
    请求体: { token: "<AliCloud LoginToken>", device_id?: "...", ... }
    返回:   { access_token, user_id, username, is_new_user? }
    """
    try:
        data = request.get_json(silent=True) or {}
        login_token = data.get('token', '')

        if not login_token:
            return jsonify({"msg": "token 不能为空"}), 400

        # 获取设备信息
        device_id = data.get('device_id')
        device_name = data.get('device_name', 'App')
        device_type = data.get('device_type', 'app')
        public_ip = data.get('public_ip')
        login_address = data.get('login_address')

        # ===== 步骤1:调用阿里云 GetMobile 获取真实手机号 =====
        phone_number = _get_mobile_from_token(login_token)

        if not phone_number:
            current_app.logger.warning(f"一键登录 - Token 验证失败,无法获取手机号")
            return jsonify({"msg": "Token 验证失败或已过期,请重试"}), 401

        # 手机号脱敏记录日志(不记录完整号码)
        masked_phone = phone_number[:3] + '****' + phone_number[-4:]
        current_app.logger.info(f"一键登录 - 获取到手机号: {masked_phone}")

        # ===== 步骤2:查找用户,不存在则自动注册 =====
        user = User.query.filter_by(phone=phone_number).first()

        is_new_user = False
        if not user:
            # 自动注册:使用手机号作为用户名前缀
            base_username = f'user_{phone_number[-4:]}'
            username = base_username
            counter = 1
            while User.query.filter_by(username=username).first():
                username = f'{base_username}_{counter}'
                counter += 1

            user = User(
                username=username,
                password_hash=generate_password_hash(str(uuid.uuid4())[:16]),  # 随机密码,不可用密码登录
                phone=phone_number,
                is_active=True,
                email_verified=False
            )
            db.session.add(user)
            db.session.commit()
            is_new_user = True
            current_app.logger.info(f"一键登录 - 新用户已自动注册: username={username}")

        # 检查用户状态
        if not user.is_active:
            return jsonify({
                "msg": "账号已被禁用",
                "need_verification": True
            }), 403

        # ===== 步骤3:生成 JWT 并返回 =====
        access_token = create_access_token(
            identity=str(user.id),
            additional_claims={'device_id': device_id}
        )

        # 保存设备信息
        save_device_info(user.id, device_id, device_name, device_type, access_token, public_ip, login_address)

        # 通知其他设备
        sse_manager.push_to_all_devices(
            user.id, 'device_list_changed',
            {'device_id': device_id, 'device_name': device_name, 'action': 'login'}
        )

        result = {
            "access_token": access_token,
            "user_id": user.id,
            "username": user.username,
            "phone_masked": masked_phone
        }

        if is_new_user:
            result['is_new_user'] = True
            result['msg'] = '登录成功(新账号已自动创建)'
        else:
            result['is_new_user'] = False
            result['msg'] = '登录成功'

        return jsonify(result)

    except Exception as e:
        current_app.logger.error(f"一键登录异常: {str(e)}", exc_info=True)
        return jsonify({"msg": f"登录失败:{str(e)}"}), 500

2.2.2 核心函数

_get_mobile_from_token(login_token) --- 阿里云 API 调用封装:

py 复制代码
def _get_mobile_from_token(login_token):
    """
    调用阿里云号码认证 GetMobile 接口,用 token 换取真实手机号。

    :param login_token: 前端从 getLoginToken 回调中获取的 token
    :return: 成功返回手机号字符串,失败返回 None
    """
    try:
        config = get_sms_config()
        access_key_id = config['access_key_id']
        access_key_secret = config['access_key_secret']

        if not access_key_id or not access_key_secret:
            current_app.logger.error("一键登录 - 未配置阿里云 AccessKey")
            return None

        from alibabacloud_dypnsapi20170525.client import Client as DypnsapiClient
        from alibabacloud_tea_openapi import models as open_api_models
        from alibabacloud_dypnsapi20170525 import models as dypns_models
        from alibabacloud_tea_util import models as util_models

        api_config = open_api_models.Config(
            access_key_id=access_key_id,
            access_key_secret=access_key_secret
        )
        api_config.endpoint = 'dypnsapi.aliyuncs.com'
        client = DypnsapiClient(api_config)

        request_model = dypns_models.GetMobileRequest(
            access_token=login_token,
            out_id=f'one_click_{int(time.time())}'
        )
        runtime = util_models.RuntimeOptions()

        response = client.get_mobile_with_options(request_model, runtime)

        # 解析响应
        body = response.body
        code = body.code

        # OK 表示成功
        if code == 'OK':
            dto = body.get_mobile_result_dto
            phone = dto.mobile if dto else None
            if phone:
                # 清理手机号中的空格和特殊字符
                phone = phone.replace(' ', '').replace('-', '')
            return phone
        else:
            msg = body.message or '未知错误'
            current_app.logger.warning(
                f"一键登录 - GetMobile 返回失败: code={code}, msg={msg}"
            )
            return None

    except Exception as e:
        current_app.logger.error(f"一键登录 - 调用 GetMobile 接口异常: {str(e)}")
        return None

save_device_info() --- 设备信息持久化:

py 复制代码
def save_device_info(user_id, device_id, device_name, device_type, token, public_ip=None, login_address=None):
    """保存或更新设备信息"""
    try:
        # 查询是否已有该设备记录
        device = UserLoginDevice.query.filter_by(user_id=user_id, device_id=device_id).first()
        
        # 优先使用前端传来的公网IP,其次从请求头获取
        if public_ip:
            login_ip = public_ip
        else:
            login_ip = get_client_ip()
        
        # 优先使用前端传来的归属地,其次查询
        if login_address:
            final_address = login_address
        else:
            final_address = get_ip_location(login_ip)
        
        # 计算token过期时间(24小时)
        expire_time = get_now() + timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 86400))
        
        if device:
            # 更新已有设备
            device.login_ip = login_ip
            device.login_address = final_address
            device.login_time = get_now()
            device.expire_time = expire_time
            device.is_online = 1
            device.token = token
            device.device_name = device_name
            device.device_type = device_type
        else:
            # 新增设备
            device = UserLoginDevice(
                user_id=user_id,
                device_id=device_id,
                device_name=device_name,
                device_type=device_type,
                login_ip=login_ip,
                login_address=final_address,
                login_time=get_now(),
                expire_time=expire_time,
                is_online=1,
                token=token
            )
            db.session.add(device)
        
        db.session.commit()
        
        # 登录成功后,从黑名单中移除该设备(允许重新登录)
        device_blacklist.remove(device_id)
        current_app.logger.info(f"设备信息保存成功: user_id={user_id}, device_id={device_id}, ip={login_ip}, address={final_address}")
    except Exception as e:
        db.session.rollback()
        current_app.logger.error(f"保存设备信息失败: {str(e)}")

三、数据交互机制

3.1 请求/响应数据结构 前端 → 后端( POST /one-click-login )

json 复制代码
// 请求体
{
    "token": "阿里云 LoginToken(从原生插件获取)",
    "device_id": "设备唯一标识",
    "device_name": "iPhone 15 Pro",
    "device_type": "ios",
    "public_ip": "42.xxx.xxx.xxx",
    "login_address": "广东省深圳市"
}

// 成功响应
{
    "access_token": "eyJhbGc...",
    "user_id": 1,
    "username": "user_5678",
    "phone_masked": "138****1234",
    "is_new_user": true,
    "msg": "登录成功(新账号已自动创建)"
}

// 失败响应
{ "msg": "Token 验证失败或已过期,请重试" }  // 401
{ "msg": "账号已被禁用" }                      // 403
{ "msg": "登录失败:..." }                     // 500

SDK 密钥获取( GET /one-click-login/sdk-info )

json 复制代码
// 成功响应
{
    "sdk_info": "Jqptjz8rn7Z+6n2XvHO7...",
    "available": true,
    "msg": "获取成功"
}

// 未配置响应
{
    "sdk_info": "",
    "available": false,
    "msg": "一键登录服务未配置"
}

3.2 完整数据流

复制代码
┌─────────────────┐                    ┌─────────────────┐                    ┌─────────────────┐
│   UniApp Client │                    │   Flask Backend │                    │   Aliyun Cloud  │
└────────┬────────┘                    └────────┬────────┘                    └────────┬────────┘
         │                                      │                                      │
         │ ① GET /one-click-login/sdk-info     │                                      │
         │─────────────────────────────────────>│                                      │
         │<─────────────────────────────────────│                                      │
         │         { sdk_info: "xxx" }          │                                      │
         │                                      │                                      │
         │ ② setAuthSDKInfo(sdkInfo)           │                                      │
         │         (原生插件调用)                │                                      │
         │                                      │                                      │
         │ ③ checkEnvAvailable(type=2)         │                                      │
         │         (原生插件调用)                │                                      │
         │                                      │                                      │
         │ ④ accelerateLoginPage(timeout)      │                                      │
         │         (原生插件调用)                │                                      │
         │                                      │                                      │
         │ ⑤ getLoginToken(timeout, uiConfig)   │                                      │
         │         (唤起授权页)                  │                                      │
         │                                      │                                      │
         │ ⑥ 获取 LoginToken 成功               │                                      │
         │<─────────────────────────────────────│                                      │
         │                                      │                                      │
         │ ⑦ POST /one-click-login             │                                      │
         │   { token, deviceInfo }             │                                      │
         │─────────────────────────────────────>│                                      │
         │                                      │                                      │
         │                                      │ ⑧ GetMobile(token)                   │
         │                                      │─────────────────────────────────────>│
         │                                      │<─────────────────────────────────────│
         │                                      │         { code: "OK", mobile }        │
         │                                      │                                      │
         │                                      │ ⑨ 查询/创建 User                     │
         │                                      │─────────────────────────────────────>│
         │                                      │         (SQLite Database)            │
         │                                      │<─────────────────────────────────────│
         │                                      │                                      │
         │                                      │ ⑩ 生成 JWT + 保存设备                 │
         │                                      │─────────────────────────────────────>│
         │                                      │<─────────────────────────────────────│
         │                                      │                                      │
         │<─────────────────────────────────────│                                      │
         │   { access_token, user_id, ... }    │                                      │
         │                                      │                                      │
         │ ⑪ 存储 token + 跳转首页              │                                      │
         └─────────────────┘                    └─────────────────┘                    └─────────────────┘

四、关键接口定义

4.1 后端 API 规范

接口 1:执行一键登录

复制代码
POST /api/one-click-login

Headers:
  Content-Type: application/json
  X-Device-Id: device_xxx

Body:
{
  "token": "string (必填) 阿里云 LoginToken",
  "device_id": "string (必填) 设备唯一标识",
  "device_name": "string (可选) 设备名称",
  "device_type": "string (可选) 设备类型 ios|android|app",
  "public_ip": "string (可选) 前端获取的公网IP",
  "login_address": "string (可选) 前端获取的IP属地"
}

Response 200:
{
  "access_token": "string JWT令牌",
  "user_id": "int 用户ID",
  "username": "string 用户名",
  "phone_masked": "string 脱敏手机号",
  "is_new_user": "boolean 是否新注册用户",
  "msg": "string 提示信息"
}

Response 400: { "msg": "token 不能为空" }
Response 401: { "msg": "Token 验证失败或已过期" }
Response 403: { "msg": "账号已被禁用" }
Response 500: { "msg": "登录失败:..." }

接口 2:获取 SDK 密钥

复制代码
GET /api/one-click-login/sdk-info

Response 200:
{
  "sdk_info": "string SDK密钥",
  "available": "boolean 是否可用",
  "msg": "string 提示信息"
}

接口 3:环境检查

复制代码
POST /api/one-click-login/check-env

Response 200:
{
  "supported": "boolean 是否支持",
  "msg": "string 提示信息"
}

4.2 前端模块导出

js 复制代码
// utils/one-click-login.js
export function init(sdkInfo) → { success, msg }
export function checkEnv() → Promise<{ supported, msg? }>
export function preLogin(timeout?) → Promise<{ success, msg? }>
export function login(options?) → Promise<{ success, data?, msg?, cancelled? }>
export function quitLoginPage() → void
export function isReady() → boolean
export function isPreLoginValid() → boolean
export function getPreLoginAge() → number
export { CODES }  // 状态码常量

4.3 前端 API 调用

js 复制代码
// apis/index.js
oneClickLogin: (data) => request.post('/one-click-login', data),
getOneClickSdkInfo: () => request.get('/one-click-login/sdk-info')

五、安全验证策略

5.1 整体安全架构

安全验证体系
访问控制
设备黑名单机制
SSE 实时推送
多设备状态同步
数据安全
SDK 密钥服务端存储
手机号脱敏日志
设备指纹追踪
Token 安全
阿里云 LoginToken
JWT access_token
Token 有效期 24h

5.2 分层安全验证流程

H5/小程序
App环境
SDK 未安装
SDK 未配置
初始化成功
运营商不支持
环境支持
预取号成功
用户取消
用户确认
Token 无效/过期
Token 有效
用户已禁用
用户正常
用户点击一键登录
第一步:客户端环境验证
拒绝:仅支持 App
第二步:SDK 初始化验证
拒绝:原生插件未安装
拒绝:SDK 密钥未配置
第三步:网络环境验证
拒绝:显示错误提示
第四步:预取号验证
第五步:用户授权
流程取消
第六步:阿里云 Token 验证
401: Token 验证失败
第七步:后端业务验证
403: 账号已被禁用
第八步:生成 JWT + 设备注册
登录成功:返回 JWT
降级:切换短信验证码

5.3 阿里云 Token 二次验证

Aliyun Backend Client Aliyun Backend Client 用户在授权页确认 alt Token 有效 Token 无效/过期 1. 调用 getLoginToken() 2. 返回 LoginToken(临时凭证) 3. POST /one-click-login { token } 4. GetMobile(token) 5. { code: "OK", mobile: "138****1234" } 6. 查询/创建用户 7. 返回 JWT 7. 返回 401 错误

5.4 安全措施清单

六、完整可视化图表

6.2 功能流程图

失败
成功
不支持
支持
切换 Tab
点击按钮
加载中/未就绪
就绪
已过期
有效
不足2秒
足够
取消
点击协议
确认登录
成功
超时/失败
成功
超时
用户确认
用户取消
其他错误
页面加载 onMounted
初始化一键登录 SDK
获取 SDK 密钥
隐藏一键登录 Tab
初始化原生插件
检查环境支持

checkEnvAvailable(type=2)
Tab 显示但按钮禁用
预取号加速

accelerateLoginPage()
等待用户操作
清空状态,切换登录方式
检查状态
忽略点击
检查预取号凭证
重新预取号
检查缓冲时间
等待缓冲
调用 getLoginToken()
唤起运营商授权页
用户操作
返回 cancelled=true
打开协议链接
获取 Token
调用后端登录接口
显示错误提示
解析结果
存储 token 和用户信息
fetchUserInfo() 获取完整信息
SSE 重新连接
跳转首页 /pages/index/index
弹窗:切换验证码登录
关闭弹窗
Toast 错误提示
恢复按钮,可重试
Toast 已取消

6.3 数据交互时序图(详细版)

SSE Manager SQLite Aliyun PNVS auth.py userApi AliCloud-NirvanaPns one-click-login.js login.vue 用户 SSE Manager SQLite Aliyun PNVS auth.py userApi AliCloud-NirvanaPns one-click-login.js login.vue 用户 【阶段一】SDK 初始化(页面加载时一次性执行) oneClickAvailable = true isOneClickReady = true 【阶段二】用户触发登录 【阶段三】后端验证与登录 userStore.token = "eyJ..." userStore.userInfo = {...} 【阶段四】登录完成 getOneClickSdkInfo() 1 GET /one-click-login/sdk-info 2 { sdk_info: "Jqpt...", available: true } 3 sdkRes 4 init(sdkRes.sdk_info) 5 setAuthSDKInfo(sdkInfo) 6 void 7 { success: true } 8 checkEnv() 9 checkEnvAvailable(2) 10 { resultCode: "600000" } 11 { supported: true } 12 preLogin(5000) 13 accelerateLoginPage(5000) 14 { resultCode: "600000" } 15 { success: true } 16 点击"本机号码一键登录" 17 isOneClickLoading = true 18 handleOneClickLogin() 19 isPreLoginValid() = true 20 getPreLoginAge() = 45000ms (>2000ms) 21 getLoginToken(8000, uiConfig) 22 拉起授权页(运营商页面) 23 { resultCode: "600001" } (授权页拉起成功) 24 点击"登录"按钮 25 检查协议勾选状态 26 { resultCode: "600000", token: "ali_token_xxx" } 27 quitLoginPage() 28 oneClickLogin({ token: "ali_token_xxx", device_id: "xxx", device_name: "iPhone 15", public_ip: "42.x.x.x", login_address: "广东省深圳市" }) 29 POST /one-click-login 30 获取阿里云 AK/SK 31 GetMobile(login_token) 32 { code: "OK", get_mobile_result_dto: { mobile: "13812345678" } } 33 User.query.filter_by(phone="13812345678") 34 null (用户不存在) 35 创建新用户 username="user_5678" password_hash=random_uuid phone="13812345678" is_active=true 36 user 对象 37 generate_access_token(identity=user.id) 38 save_device_info(user.id, device_id, ...) 39 push_to_all_devices( user.id, "device_list_changed", { device_id, action: "login" }) 40 void 41 { access_token: "eyJ...", user_id: 5, username: "user_5678", phone_masked: "138****5678", is_new_user: true, msg: "登录成功(新账号已自动创建)" } 42 result 43 { success: true, data: {...} } 44 fetchUserInfo() 45 sseClient.reconnect() 46 Toast "新账号已自动创建并登录" 47 switchTab(/pages/index/index) 48

6.5 安全验证流程图

空值
有效值
code not equal OK
code = OK
不存在
已存在
is_active=False
is_active=True
一键登录请求入口
Token

合法性验证
返回 400

token 不能为空
提取设备信息
调用阿里云

GetMobile API
阿里云

返回结果
返回 401

Token 验证失败

或已过期
获取手机号

13812345678
日志记录

(脱敏: 138****5678)
查询用户

User.query.filter_by(phone)
用户

存在性检查
自动注册新用户

username: user_5678

password: random_uuid

is_active: True

email_verified: False
账号

状态检查
db.session.commit()
生成 JWT Token

含 user_id + device_id claim
返回 403

账号已被禁用
保存设备信息

save_device_info()
从黑名单移除设备

device_blacklist.remove()
SSE 推送

device_list_changed
返回成功响应

access_token + user_id
登录流程结束
后端验证
授权页流程
前端预检查
非App
App
未安装
已安装
未初始化
已初始化
已过期
有效
不足
足够
600001
600000
700000
超时
失败
成功
不存在
存在
禁用
正常
平台检测

isAppEnv()
return: 仅支持App
SDK 检测

_sdkModule ≠ null
return: 原生插件未安装
初始化检测

_initialized = true
return: SDK 未初始化
预取号凭证

isPreLoginValid()
preLogin() 重新预取
缓冲时间

age >= 2000ms
等待缓冲
准备调用授权
getLoginToken(timeout=8000)
回调触发

resultCode
授权页拉起成功

_isLoginPageShowing=true
获取 Token 成功
用户取消

return cancelled=true
超时处理

return timeout error
quitLoginPage()
返回 Token
POST /one-click-login
Token 验证
401 Error
用户查询
自动注册
状态检查
403 Error
生成 JWT
保存设备
SSE 推送
返回 JWT

6.6 登录状态机图

应用启动
页面 onMounted
init() + checkEnv() 成功
init() 失败
Tab 不显示
用户点击登录
checkEnv() 返回 false
Tab 显示但按钮禁用
调用 preLogin()
preLogin() 返回成功
等待 2 秒缓冲
缓冲时间到达
调用 getLoginToken()
resultCode = 600001
resultCode = 600000
resultCode = 700000
超过 8+5 秒无响应
用户点击登录
用户返回
发送 token 到后端
返回 cancelled=true
返回 timeout error
后端返回 JWT
后端返回错误
跳转首页
显示错误,可重试
NotReady
Initializing
SDKReady
InitFailed
PreLoginNeeded
EnvNotSupported
PreLogging
PreLoginDone
BufferWaiting
TokenGetting
GettingToken
AuthPageShown
TokenReceived
UserCancelled
Timeout
BackendVerify
LoginSuccess
LoginFailed

相关推荐
Database_Cool_1 小时前
阿里云 RDS MySQL 降本增效实战:从规格选型到成本优化,月成本降低64%全攻略
mysql·阿里云·云计算
大模型码小白10 小时前
【Python零基础教程】继承、多态与魔法函数:面向对象编程三大核心特性详解
java·大数据·开发语言·人工智能·python·ai编程
麻雀飞吧10 小时前
最新量化学习路径,交易认知和技术实现要并行
人工智能·python
C^h14 小时前
python函数学习
人工智能·python·机器学习
Fanta丶14 小时前
4.Python set()集合、dict(字典、映射)、 数据容器的通用功能
python
决战灬14 小时前
langgraph之interrupt(理论篇)
人工智能·python·agent
兜客互动15 小时前
2026年AI关键词拓展挖掘软件,高效助力内容创作精准获流
人工智能·python
weixin_5386019715 小时前
智能体测开Day30pytest测试框架
python
MC皮蛋侠客15 小时前
uv 系列(七):CI/CD、Docker 与私有索引——生产级交付
python·ci/cd·docker·uv
邪神与厨二病15 小时前
牛客周赛 Round 153
python·算法