个人开发者接入阿里云号码认证服务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

相关推荐
半城抹茶2 小时前
TradingAgents-CN 项目目录文档
python
光影6272 小时前
Selenium自动化测试---实战踩坑实录
python·selenium·测试工具·百度
HappyAcmen2 小时前
2.lcut返回列表用法
python
Json____2 小时前
Python练习题集-文件处理、数据管理与网络编程实战小项目15个
python·编程·编程学习·练习题·python学习
星空椰2 小时前
Python 使用飞书 API 获取部门直属用户列表(递归获取所有部门 + 导出 Excel)
python·飞书
l1t2 小时前
在aarch64机器上安装clang来生成codonjit python模块
开发语言·python
辰尘_星启2 小时前
【Linux】Python Socket编程指南
linux·python·socket·系统·通信
南宫萧幕2 小时前
基于 Simulink 与 Python 联合仿真的 eVTOL 强化学习全链路实战
开发语言·人工智能·python·算法·机器学习·控制
Amctwd3 小时前
【Python】从Excel中按行提取图片
java·python·excel