前言
这两天折腾小程序,遇到了一个不可绕过的话题:用户登录 。我了解到小程序登录目前的两种主流方式:
- 手机号授权登录
- 用户授权登录
第一种需要消耗手机号授权组件的次数,属于收费的,我就想既然有自己的服务器和数据库那就没必要了,就像着自己搭建后端api接口,给小程序用。
于是昨天晚上我阅遍全网微信登录文章,对线各大厂商 AI,奋战了18h,终于是把用户登录做完了!现在我要写一篇博文,来记录我的过程和心得。
完成后的后端(接口+数据库),可以做到:
| 项目 | 状态 | 说明 |
|---|---|---|
| 用户隔离 | ✅ | openid 唯一索引 + 外键约束 |
| 防重复注册 | ✅ | ON DUPLICATE KEY UPDATE |
| Token 安全 | ✅ | 固定 64 位密钥 + HMAC-SHA256 |
| 数据持久化 | ✅ | MySQL + 预处理语句 |
| 订单隔离 | ✅ | WHERE openid = ? 自动过滤 |
接口调用:https://api.xxx.com/api.php
正文
按我的理解,流程图长这样:
小程序 wx.login() 获取 code → 后端用 code 换取 openid+session_key → 生成 JWT token → 小程序存储 token 和 session_key → 用户授权获取 encryptedData+iv → 后端解密授权信息并保存到 MySQL → 登录完成
了解完流程,需要确保你有以下条件:
- 一台服务器+域名(https://)
- 小程序项目
- PHP+MySQL 环境
小程序端
App.js
globalData: {
customTabBar: null,
userInfo: null,
phone: null,
token: null,
baseUrl: 'https://api.xxx.com/api.php' // 你的后端接口地址
},
login(cb) {
wx.login({
success: (res) => {
if (res.code) {
wx.request({
url: `${this.globalData.baseUrl}?action=login`,
method: 'POST',
header: { 'content-type': 'application/x-www-form-urlencoded' },
data: { code: res.code },
success: (reqRes) => {
const { token, session_key, exists } = reqRes.data;
if (!token) {
wx.showToast({ title: '登录失败', icon: 'none' });
return;
}
this.globalData.token = token;
this.globalData.sessionKey = session_key;
wx.setStorageSync('token', token);
wx.setStorageSync('sessionKey', session_key); // 持久化存储
cb && cb();
}
});
}
}
});
},用户登录界面:
// 获取用户信息授权
onGetUserInfo(e) {
if (e.detail.errMsg !== 'getUserInfo:ok') return;
const { encryptedData, iv } = e.detail;
// 确保 session_key 存在且未过期
if (!app.globalData.sessionKey) {
// 尝试从缓存恢复
app.globalData.sessionKey = wx.getStorageSync('sessionKey');
}
if (!app.globalData.sessionKey) {
wx.showToast({ title: '登录已过期,请重启小程序', icon: 'none' });
// 重新登录
app.login(() => {
this.onGetUserInfo(e); // 重试
});
return;
}
wx.request({
url: `${app.globalData.baseUrl}?action=save_userinfo`,
method: 'POST',
header: {
'Authorization': `Bearer ${app.globalData.token}`,
'content-type': 'application/x-www-form-urlencoded'
},
data: {
encryptedData,
iv,
session_key: app.globalData.sessionKey
},
success: (res) => {
console.log('/save_userinfo 返回', res.data);
if (res.data.success) {
app.globalData.userInfo = res.data.userInfo;
this.updateUserInfo();
wx.showToast({ title: '授权成功', icon: 'success' });
} else if (res.data.error === '未授权或token过期') {
// Token 过期,重新登录
app.login(() => {
this.onGetUserInfo(e); // 重试
});
} else {
wx.showToast({ title: res.data.error || '保存失败', icon: 'none' });
}
},
fail: (err) => {
console.error('请求失败', err);
wx.showToast({ title: '网络错误', icon: 'none' });
}
});
},// 更新用户信息
updateUserInfo() {
const app = getApp();
// 用最新 globalData 覆盖 page.data
this.setData({
'userInfo.avatar': app.globalData.userInfo?.avatarUrl || '/assets/logo.png',
'userInfo.nickname': app.globalData.userInfo?.nickName || '点击登录',
'userInfo.phone': app.globalData.phone || ''
});
console.log('刷新头像昵称', this.data.userInfo);
},
// 点击用户信息区域
onUserInfoTap() {
if (!app.globalData.token) {
wx.showToast({
title: '请先登录',
icon: 'none'
});
return;
}
if (!app.globalData.userInfo) {
this.showAuthModal();
} else {
// 已登录,可以编辑信息
console.log('用户已登录,可以编辑信息');
}
},// 显示授权弹窗
showAuthModal() {
wx.showModal({
title: '授权登录',
content: '需要获取您的微信头像和昵称',
success: (res) => {
if (res.confirm) {
// 用户同意,显示授权按钮
this.setData({ showAuthButton: true });
}
}
});
},后端PHP:
<?php
// api.php
ini_set('display_errors', 1);
error_reporting(E_ALL);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *'); // 生产环境改为小程序域名
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// ==================== 配置区 ====================
define('WX_APPID', '你的小程序AppID');
define('WX_SECRET', '小程序后台生成');
define('JWT_SECRET', '自行生成');
// MySQL 配置
define('DB_HOST', 'localhost');
define('DB_NAME', '数据库名');
define('DB_USER', '数据库用户名');
define('DB_PASS', '数据库密码');
function get_db() {
static $pdo;
if (!$pdo) {
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]
);
} catch (PDOException $e) {
error_log("DB Connection Error: " . $e->getMessage());
exit(json_encode(['error' => '系统错误']));
}
}
return $pdo;
}
function get_post_data() {
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (strpos($contentType, 'application/json') !== false) {
return json_decode(file_get_contents('php://input'), true) ?: [];
}
return $_POST;
}
function generate_token($openid) {
$payload = ['openid' => $openid, 'iat' => time(), 'exp' => time() + 86400 * 7];
$header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64_encode(json_encode($payload));
$signature = hash_hmac('sha256', "$header.$payload", JWT_SECRET);
return "$header.$payload.$signature";
}
function verify_token($token) {
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
$signature = hash_hmac('sha256', "$parts[0].$parts[1]", JWT_SECRET);
if (!hash_equals($signature, $parts[2])) return null;
$payload = json_decode(base64_decode($parts[1]), true);
if (!$payload || $payload['exp'] < time()) return null;
return $payload['openid'];
}
function wx_decrypt($encryptedData, $iv, $sessionKey) {
$key = base64_decode($sessionKey);
$data = base64_decode($encryptedData);
$iv = base64_decode($iv);
if (!$key || !$data || !$iv) return null;
$decrypted = openssl_decrypt($data, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv);
return $decrypted ? json_decode($decrypted, true) : null;
}
function generate_order_no($pdo) {
$date = date('Ymd');
$pdo->beginTransaction();
try {
$stmt = $pdo->query("SELECT MAX(CAST(SUBSTRING_INDEX(order_no, '_', -1) AS UNSIGNED)) as max_seq
FROM orders WHERE DATE(created_at) = CURDATE() FOR UPDATE");
$nextSeq = ($stmt->fetchColumn() ?? 0) + 1;
$orderNo = "{$date}_{$nextSeq}";
$pdo->commit();
return $orderNo;
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
$action = $_GET['action'] ?? '';
$input = get_post_data();
try {
switch ($action) {
case 'login': wechat_login($input); break;
case 'save_userinfo': wechat_save_userinfo($input); break;
case 'save_phone': wechat_save_phone($input); break;
case 'user_info': wechat_user_info(); break;
case 'create_order': wechat_create_order($input); break;
case 'get_orders': wechat_get_orders(); break;
default: echo json_encode(['error' => '未知接口']);
}
} catch (Exception $e) {
error_log("API Error: " . $e->getMessage());
echo json_encode(['error' => '系统繁忙,请稍后重试']);
}
// 登录/注册(自动处理重复登录)
function wechat_login($input) {
$code = $input['code'] ?? '';
if (!$code) exit(json_encode(['error' => '缺少code']));
$url = "https://api.weixin.qq.com/sns/jscode2session?appid=" . WX_APPID . "&secret=" . WX_SECRET . "&js_code={$code}&grant_type=authorization_code";
$res = file_get_contents($url);
$arr = json_decode($res, true);
if (isset($arr['errcode'])) {
exit(json_encode(['error' => '微信接口错误', 'msg' => $arr['errmsg']]));
}
$openid = $arr['openid'];
$session_key = $arr['session_key'];
$pdo = get_db();
// 核心:ON DUPLICATE KEY UPDATE 确保同一 openid 只存在一条记录
$stmt = $pdo->prepare("
INSERT INTO users (openid, session_key, updated_at)
VALUES (:openid, :session_key, NOW())
ON DUPLICATE KEY UPDATE session_key = VALUES(session_key), updated_at = NOW()
");
$stmt->execute([
':openid' => $openid,
':session_key' => $session_key
]);
$token = generate_token($openid);
// 检查是否已授权
$stmt = $pdo->prepare("SELECT nickname, points FROM users WHERE openid = ?");
$stmt->execute([$openid]);
$user = $stmt->fetch();
echo json_encode([
'token' => $token,
'session_key' => $session_key,
'exists' => !empty($user['nickname']),
'points' => $user['points'] ?? 0
]);
}
// 在 wx_decrypt 函数后添加 session_key 刷新机制
function refresh_session_key($openid, $code) {
// 用新 code 换取新 session_key
$url = "https://api.weixin.qq.com/sns/jscode2session?appid=" . WX_APPID . "&secret=" . WX_SECRET . "&js_code={$code}&grant_type=authorization_code";
$res = file_get_contents($url);
$arr = json_decode($res, true);
if (isset($arr['session_key'])) {
$pdo = get_db();
$stmt = $pdo->prepare("UPDATE users SET session_key = ? WHERE openid = ?");
$stmt->execute([$arr['session_key'], $openid]);
return $arr['session_key'];
}
return null;
}
// 保存用户信息
function wechat_save_userinfo($input) {
// 添加内部 try-catch,直接暴露错误
try {
$token = explode(' ', $_SERVER['HTTP_AUTHORIZATION'] ?? '')[1] ?? '';
$openid = verify_token($token);
if (!$openid) exit(json_encode(['error' => '未授权或token过期']));
$encryptedData = $input['encryptedData'] ?? '';
$iv = $input['iv'] ?? '';
$sessionKey = $input['session_key'] ?? '';
if (!$sessionKey) {
exit(json_encode(['error' => '缺少 session_key']));
}
$userInfo = wx_decrypt($encryptedData, $iv, $sessionKey);
if (!$userInfo) {
exit(json_encode(['error' => '解密失败']));
}
$pdo = get_db();
$stmt = $pdo->prepare("
UPDATE users
SET nickname = ?, avatar_url = ?, gender = ?, city = ?, province = ?, country = ?, updated_at = NOW()
WHERE openid = ?
");
$stmt->execute([
$userInfo['nickName'] ?? '微信用户',
$userInfo['avatarUrl'] ?? '',
$userInfo['gender'] ?? 0,
$userInfo['city'] ?? '',
$userInfo['province'] ?? '',
$userInfo['country'] ?? '',
$openid
]);
echo json_encode(['success' => true, 'userInfo' => $userInfo]);
} catch (Exception $e) {
// 直接返回真实错误,不隐藏
echo json_encode(['error' => 'DEBUG: ' . $e->getMessage()]);
exit;
}
}
// 保存手机号
function wechat_save_phone($input) {
//仅做展示功能
}
// 创建订单
function wechat_create_order($input) {
//仅做展示功能
}
// 获取用户订单列表
function wechat_get_orders() {
//仅做展示功能
}总结
第 1 步:获取临时凭证
小程序调用 wx.login(),微信返回一个只能用一次的 code(有效期 5 分钟)。这个 code 就像一次性验证码,用来证明"这个用户正在登录"。
第 2 步:换取用户身份标识
后端收到 code 后,立刻请求微信服务器,用 code 换两条核心信息:
openid:用户的微信唯一 ID,一辈子不变,相当于用户的"数字身份证号"session_key:本次登录的临时加密密钥,30 分钟后失效
第 3 步:生成访问令牌
后端用 openid + 固定密钥 + 过期时间,生成一个 JWT Token(一串加密字符串)。Token 就是用户的"会员证",小程序把它存起来,之后每次请求都带着,证明"我已登录"。
第 4 步:授权并加密传输
用户点击"授权"按钮后,微信用 session_key 把头像、昵称加密成 encryptedData。小程序把这个加密包 + Token 一起发给后端。
第 5 步:解密并持久化
后端验证 Token 有效 → 用 session_key 解密出用户信息 → 把 openid、昵称、头像等存入 MySQL 数据库。下次用户再来,直接查 openid 就知道他是谁。
安全保障:
- 身份锁 :
openid全球唯一,数据库里设成"不可重复",保证一个人只对应一条记录 - 传输锁 :
session_key加密,就算信息被拦截,没有密钥也解不开 - 权限锁 :后端每个查询都强制加上
WHERE openid='xxx',用户只能读自己的数据,不可能看到别人的订单
评论