auth.php 10.5 KB
<?php
/**
 * 认证模块
 * 处理微信登录、Session 管理
 */

class AuthService
{
    private $pdo;
    private $config;
    private $redis;

    public function __construct($pdo, $config, $redis = null)
    {
        $this->pdo = $pdo;
        $this->config = $config;
        $this->redis = $redis;
    }

    /**
     * 微信小程序登录
     * @param string $code 微信 login code
     * @param string $phone 手机号
     * @param string $phoneCode 手机号 code (用于获取真实手机号)
     * @return array
     */
    public function wechatLogin($code, $phone = null, $phoneCode = null)
    {
        // 1. 用 code 换取 openid
        $wxSession = $this->code2Session($code);
        if (!$wxSession || empty($wxSession['openid'])) {
            return ['ok' => false, 'error' => '微信登录失败'];
        }

        $openid = $wxSession['openid'];
        $unionid = $wxSession['unionid'] ?? null;

        // 2. 如果有 phoneCode,获取真实手机号
        if ($phoneCode) {
            $this->logDebug("Fetching phone with code: ... " . substr($phoneCode, -4));
            $realPhone = $this->getPhoneNumber($phoneCode);
            $this->logDebug("Resolved phone number: " . ($realPhone ?: 'NULL'));
            if ($realPhone) {
                $phone = $realPhone;
            }
        }

        $this->logDebug("Final Phone before check: " . ($phone ?: 'EMPTY'));

        if (empty($phone)) {
            $this->logDebug("Error: Phone is empty");
            return ['ok' => false, 'error' => '手机号不能为空'];
        }

        // 3. 查找或创建用户
        try {
            $user = $this->findOrCreateUser($phone, $openid, $unionid);
        } catch (Exception $e) {
            $this->logDebug("Exception in findOrCreateUser: " . $e->getMessage());
            return ['ok' => false, 'error' => 'Database Error: ' . $e->getMessage()];
        }

        if (!$user) {
            $this->logDebug("Error: Failed to find/create user (Result is null)");
            return ['ok' => false, 'error' => '用户创建失败'];
        }

        $this->logDebug("User ID: " . $user['id']);

        // 4. 创建 Session
        try {
            $token = $this->createSession($user['id']);
        } catch (Exception $e) {
            $this->logDebug("Exception in createSession: " . $e->getMessage());
            return ['ok' => false, 'error' => 'Session Error: ' . $e->getMessage()];
        }

        if (!$token) {
            $this->logDebug("Error: Failed to create session");
            return ['ok' => false, 'error' => 'Session 创建失败'];
        }

        // 5. 获取绑定的设备
        $devices = $this->getUserDevices($user['id']);

        return [
            'ok' => true,
            'token' => $token,
            'user' => [
                'id' => $user['id'],
                'phone' => $user['phone'],
                'nickname' => $user['nickname'],
                'avatar_url' => $user['avatar_url'],
            ],
            'devices' => $devices
        ];
    }

    /**
     * 验证 Token
     * @param string $token
     * @return array|null 用户信息或 null
     */
    public function verifyToken($token)
    {
        if (empty($token)) {
            return null;
        }

        // 先从 Redis 缓存查
        if ($this->redis) {
            $cached = $this->redis->get("session:$token");
            if ($cached) {
                return json_decode($cached, true);
            }
        }

        // 从数据库查
        $stmt = $this->pdo->prepare("
            SELECT s.*, u.phone, u.nickname, u.avatar_url
            FROM sessions s
            JOIN users u ON s.user_id = u.id
            WHERE s.token = ? AND s.expires_at > NOW()
        ");
        $stmt->execute([$token]);
        $session = $stmt->fetch();

        if (!$session) {
            return null;
        }

        $user = [
            'id' => $session['user_id'],
            'phone' => $session['phone'],
            'nickname' => $session['nickname'],
            'avatar_url' => $session['avatar_url'],
        ];

        // 缓存到 Redis
        if ($this->redis) {
            $ttl = strtotime($session['expires_at']) - time();
            if ($ttl > 0) {
                $this->redis->setex("session:$token", $ttl, json_encode($user));
            }
        }

        return $user;
    }

    /**
     * 注销登录
     * @param string $token
     * @return bool
     */
    public function logout($token)
    {
        // 删除数据库记录
        $stmt = $this->pdo->prepare("DELETE FROM sessions WHERE token = ?");
        $stmt->execute([$token]);

        // 删除 Redis 缓存
        if ($this->redis) {
            $this->redis->del("session:$token");
        }

        return true;
    }

    /**
     * 微信 code2Session
     */
    private function code2Session($code)
    {
        $appId = $this->config['wechat']['app_id'];
        $appSecret = $this->config['wechat']['app_secret'];

        if (empty($appId) || empty($appSecret)) {
            // 开发模式:返回模拟数据
            return [
                'openid' => 'dev_openid_' . substr(md5($code), 0, 16),
                'session_key' => 'dev_session_key',
            ];
        }

        $url = "https://api.weixin.qq.com/sns/jscode2session?" . http_build_query([
            'appid' => $appId,
            'secret' => $appSecret,
            'js_code' => $code,
            'grant_type' => 'authorization_code'
        ]);

        $response = file_get_contents($url);
        if (!$response) {
            return null;
        }

        $data = json_decode($response, true);
        if (isset($data['errcode']) && $data['errcode'] != 0) {
            error_log("WeChat code2Session error: " . json_encode($data));
            return null;
        }

        return $data;
    }

    /**
     * 获取微信手机号
     */
    private function logDebug($msg)
    {
        file_put_contents(__DIR__ . '/debug.log', "[" . date('Y-m-d H:i:s') . "] " . $msg . "\n", FILE_APPEND);
    }

    private function getPhoneNumber($phoneCode)
    {
        $appId = $this->config['wechat']['app_id'];
        $appSecret = $this->config['wechat']['app_secret'];

        $this->logDebug("Checking WeChat Config - AppID: {$appId}, SecretLen: " . strlen($appSecret));

        if (empty($appId) || empty($appSecret)) {
            $this->logDebug("AppID or Secret is empty!");
            return null; // 开发模式不支持
        }

        // 1. 获取 access_token
        $tokenUrl = "https://api.weixin.qq.com/cgi-bin/token?" . http_build_query([
            'grant_type' => 'client_credential',
            'appid' => $appId,
            'secret' => $appSecret,
        ]);

        $tokenRaw = file_get_contents($tokenUrl);
        $this->logDebug("Access Token Raw Response: {$tokenRaw}");

        $tokenRes = json_decode($tokenRaw, true);
        if (empty($tokenRes['access_token'])) {
            $this->logDebug("Failed to get access token.");
            return null;
        }

        // 2. 获取手机号
        $phoneUrl = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" . $tokenRes['access_token'];
        $phoneRaw = file_get_contents($phoneUrl, false, stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => 'Content-Type: application/json',
                'content' => json_encode(['code' => $phoneCode])
            ]
        ]));

        $this->logDebug("Phone Number Raw Response: {$phoneRaw}");

        $phoneRes = json_decode($phoneRaw, true);

        if (isset($phoneRes['phone_info']['purePhoneNumber'])) {
            return $phoneRes['phone_info']['purePhoneNumber'];
        }

        $this->logDebug("Failed to parse phone number from response.");
        return null;
    }

    /**
     * 查找或创建用户
     */
    private function findOrCreateUser($phone, $openid, $unionid = null)
    {
        // 先按手机号查找
        $stmt = $this->pdo->prepare("SELECT * FROM users WHERE phone = ?");
        $stmt->execute([$phone]);
        $user = $stmt->fetch();

        if ($user) {
            // 更新 openid
            if ($openid && $user['wx_openid'] !== $openid) {
                $stmt = $this->pdo->prepare("UPDATE users SET wx_openid = ?, wx_unionid = ? WHERE id = ?");
                $stmt->execute([$openid, $unionid, $user['id']]);
            }
            return $user;
        }

        // 创建新用户
        $stmt = $this->pdo->prepare("
            INSERT INTO users (phone, wx_openid, wx_unionid, nickname)
            VALUES (?, ?, ?, ?)
        ");
        $nickname = '用户' . substr($phone, -4);
        $stmt->execute([$phone, $openid, $unionid, $nickname]);

        return [
            'id' => $this->pdo->lastInsertId(),
            'phone' => $phone,
            'wx_openid' => $openid,
            'wx_unionid' => $unionid,
            'nickname' => $nickname,
            'avatar_url' => null,
        ];
    }

    /**
     * 创建 Session
     */
    private function createSession($userId)
    {
        $token = bin2hex(random_bytes(32));
        $ttl = $this->config['session']['ttl'];
        $expiresAt = date('Y-m-d H:i:s', time() + $ttl);

        // 单点登录:删除该用户的其他 session
        if ($this->config['session']['single_login']) {
            $stmt = $this->pdo->prepare("SELECT token FROM sessions WHERE user_id = ?");
            $stmt->execute([$userId]);
            $oldTokens = $stmt->fetchAll(\PDO::FETCH_COLUMN);

            // 删除 Redis 缓存
            if ($this->redis && $oldTokens) {
                foreach ($oldTokens as $oldToken) {
                    $this->redis->del("session:$oldToken");
                }
            }

            // 删除数据库记录
            $stmt = $this->pdo->prepare("DELETE FROM sessions WHERE user_id = ?");
            $stmt->execute([$userId]);
        }

        // 插入新 session
        $stmt = $this->pdo->prepare("
            INSERT INTO sessions (user_id, token, expires_at)
            VALUES (?, ?, ?)
        ");
        $stmt->execute([$userId, $token, $expiresAt]);

        return $token;
    }

    /**
     * 获取用户绑定的设备
     */
    private function getUserDevices($userId)
    {
        $stmt = $this->pdo->prepare("
            SELECT d.id, d.name, d.status, d.last_seen, b.is_primary, b.bound_at
            FROM user_device_bindings b
            JOIN devices d ON b.device_id = d.id
            WHERE b.user_id = ?
            ORDER BY b.is_primary DESC, b.bound_at ASC
        ");
        $stmt->execute([$userId]);
        return $stmt->fetchAll();
    }
}