手机号一键登录功能全面分析
一、系统架构总览
该功能基于**阿里云号码认证服务(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