Commit 7b34b8cb0c24f47e2c44662c8a5a220bc9893e6c

Authored by 谭苏航
1 parent 38603a35

ss

  1 +<?php
  2 +/**
  3 + * 认证模块
  4 + * 处理微信登录、Session 管理
  5 + */
  6 +
  7 +class AuthService
  8 +{
  9 + private $pdo;
  10 + private $config;
  11 + private $redis;
  12 +
  13 + public function __construct($pdo, $config, $redis = null)
  14 + {
  15 + $this->pdo = $pdo;
  16 + $this->config = $config;
  17 + $this->redis = $redis;
  18 + }
  19 +
  20 + /**
  21 + * 微信小程序登录
  22 + * @param string $code 微信 login code
  23 + * @param string $phone 手机号
  24 + * @param string $phoneCode 手机号 code (用于获取真实手机号)
  25 + * @return array
  26 + */
  27 + public function wechatLogin($code, $phone = null, $phoneCode = null)
  28 + {
  29 + // 1. 用 code 换取 openid
  30 + $wxSession = $this->code2Session($code);
  31 + if (!$wxSession || empty($wxSession['openid'])) {
  32 + return ['ok' => false, 'error' => '微信登录失败'];
  33 + }
  34 +
  35 + $openid = $wxSession['openid'];
  36 + $unionid = $wxSession['unionid'] ?? null;
  37 +
  38 + // 2. 如果有 phoneCode,获取真实手机号
  39 + if ($phoneCode) {
  40 + $realPhone = $this->getPhoneNumber($phoneCode);
  41 + if ($realPhone) {
  42 + $phone = $realPhone;
  43 + }
  44 + }
  45 +
  46 + if (empty($phone)) {
  47 + return ['ok' => false, 'error' => '手机号不能为空'];
  48 + }
  49 +
  50 + // 3. 查找或创建用户
  51 + $user = $this->findOrCreateUser($phone, $openid, $unionid);
  52 + if (!$user) {
  53 + return ['ok' => false, 'error' => '用户创建失败'];
  54 + }
  55 +
  56 + // 4. 创建 Session
  57 + $token = $this->createSession($user['id']);
  58 + if (!$token) {
  59 + return ['ok' => false, 'error' => 'Session 创建失败'];
  60 + }
  61 +
  62 + // 5. 获取绑定的设备
  63 + $devices = $this->getUserDevices($user['id']);
  64 +
  65 + return [
  66 + 'ok' => true,
  67 + 'token' => $token,
  68 + 'user' => [
  69 + 'id' => $user['id'],
  70 + 'phone' => $user['phone'],
  71 + 'nickname' => $user['nickname'],
  72 + 'avatar_url' => $user['avatar_url'],
  73 + ],
  74 + 'devices' => $devices
  75 + ];
  76 + }
  77 +
  78 + /**
  79 + * 验证 Token
  80 + * @param string $token
  81 + * @return array|null 用户信息或 null
  82 + */
  83 + public function verifyToken($token)
  84 + {
  85 + if (empty($token)) {
  86 + return null;
  87 + }
  88 +
  89 + // 先从 Redis 缓存查
  90 + if ($this->redis) {
  91 + $cached = $this->redis->get("session:$token");
  92 + if ($cached) {
  93 + return json_decode($cached, true);
  94 + }
  95 + }
  96 +
  97 + // 从数据库查
  98 + $stmt = $this->pdo->prepare("
  99 + SELECT s.*, u.phone, u.nickname, u.avatar_url
  100 + FROM sessions s
  101 + JOIN users u ON s.user_id = u.id
  102 + WHERE s.token = ? AND s.expires_at > NOW()
  103 + ");
  104 + $stmt->execute([$token]);
  105 + $session = $stmt->fetch();
  106 +
  107 + if (!$session) {
  108 + return null;
  109 + }
  110 +
  111 + $user = [
  112 + 'id' => $session['user_id'],
  113 + 'phone' => $session['phone'],
  114 + 'nickname' => $session['nickname'],
  115 + 'avatar_url' => $session['avatar_url'],
  116 + ];
  117 +
  118 + // 缓存到 Redis
  119 + if ($this->redis) {
  120 + $ttl = strtotime($session['expires_at']) - time();
  121 + if ($ttl > 0) {
  122 + $this->redis->setex("session:$token", $ttl, json_encode($user));
  123 + }
  124 + }
  125 +
  126 + return $user;
  127 + }
  128 +
  129 + /**
  130 + * 注销登录
  131 + * @param string $token
  132 + * @return bool
  133 + */
  134 + public function logout($token)
  135 + {
  136 + // 删除数据库记录
  137 + $stmt = $this->pdo->prepare("DELETE FROM sessions WHERE token = ?");
  138 + $stmt->execute([$token]);
  139 +
  140 + // 删除 Redis 缓存
  141 + if ($this->redis) {
  142 + $this->redis->del("session:$token");
  143 + }
  144 +
  145 + return true;
  146 + }
  147 +
  148 + /**
  149 + * 微信 code2Session
  150 + */
  151 + private function code2Session($code)
  152 + {
  153 + $appId = $this->config['wechat']['app_id'];
  154 + $appSecret = $this->config['wechat']['app_secret'];
  155 +
  156 + if (empty($appId) || empty($appSecret)) {
  157 + // 开发模式:返回模拟数据
  158 + return [
  159 + 'openid' => 'dev_openid_' . substr(md5($code), 0, 16),
  160 + 'session_key' => 'dev_session_key',
  161 + ];
  162 + }
  163 +
  164 + $url = "https://api.weixin.qq.com/sns/jscode2session?" . http_build_query([
  165 + 'appid' => $appId,
  166 + 'secret' => $appSecret,
  167 + 'js_code' => $code,
  168 + 'grant_type' => 'authorization_code'
  169 + ]);
  170 +
  171 + $response = file_get_contents($url);
  172 + if (!$response) {
  173 + return null;
  174 + }
  175 +
  176 + $data = json_decode($response, true);
  177 + if (isset($data['errcode']) && $data['errcode'] != 0) {
  178 + error_log("WeChat code2Session error: " . json_encode($data));
  179 + return null;
  180 + }
  181 +
  182 + return $data;
  183 + }
  184 +
  185 + /**
  186 + * 获取微信手机号
  187 + */
  188 + private function getPhoneNumber($phoneCode)
  189 + {
  190 + $appId = $this->config['wechat']['app_id'];
  191 + $appSecret = $this->config['wechat']['app_secret'];
  192 +
  193 + if (empty($appId) || empty($appSecret)) {
  194 + return null; // 开发模式不支持
  195 + }
  196 +
  197 + // 1. 获取 access_token
  198 + $tokenUrl = "https://api.weixin.qq.com/cgi-bin/token?" . http_build_query([
  199 + 'grant_type' => 'client_credential',
  200 + 'appid' => $appId,
  201 + 'secret' => $appSecret,
  202 + ]);
  203 + $tokenRes = json_decode(file_get_contents($tokenUrl), true);
  204 + if (empty($tokenRes['access_token'])) {
  205 + return null;
  206 + }
  207 +
  208 + // 2. 获取手机号
  209 + $phoneUrl = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" . $tokenRes['access_token'];
  210 + $phoneRes = json_decode(file_get_contents($phoneUrl, false, stream_context_create([
  211 + 'http' => [
  212 + 'method' => 'POST',
  213 + 'header' => 'Content-Type: application/json',
  214 + 'content' => json_encode(['code' => $phoneCode])
  215 + ]
  216 + ])), true);
  217 +
  218 + if (isset($phoneRes['phone_info']['purePhoneNumber'])) {
  219 + return $phoneRes['phone_info']['purePhoneNumber'];
  220 + }
  221 +
  222 + return null;
  223 + }
  224 +
  225 + /**
  226 + * 查找或创建用户
  227 + */
  228 + private function findOrCreateUser($phone, $openid, $unionid = null)
  229 + {
  230 + // 先按手机号查找
  231 + $stmt = $this->pdo->prepare("SELECT * FROM users WHERE phone = ?");
  232 + $stmt->execute([$phone]);
  233 + $user = $stmt->fetch();
  234 +
  235 + if ($user) {
  236 + // 更新 openid
  237 + if ($openid && $user['wx_openid'] !== $openid) {
  238 + $stmt = $this->pdo->prepare("UPDATE users SET wx_openid = ?, wx_unionid = ? WHERE id = ?");
  239 + $stmt->execute([$openid, $unionid, $user['id']]);
  240 + }
  241 + return $user;
  242 + }
  243 +
  244 + // 创建新用户
  245 + $stmt = $this->pdo->prepare("
  246 + INSERT INTO users (phone, wx_openid, wx_unionid, nickname)
  247 + VALUES (?, ?, ?, ?)
  248 + ");
  249 + $nickname = '用户' . substr($phone, -4);
  250 + $stmt->execute([$phone, $openid, $unionid, $nickname]);
  251 +
  252 + return [
  253 + 'id' => $this->pdo->lastInsertId(),
  254 + 'phone' => $phone,
  255 + 'wx_openid' => $openid,
  256 + 'nickname' => $nickname,
  257 + 'avatar_url' => null,
  258 + ];
  259 + }
  260 +
  261 + /**
  262 + * 创建 Session
  263 + */
  264 + private function createSession($userId)
  265 + {
  266 + $token = bin2hex(random_bytes(32));
  267 + $ttl = $this->config['session']['ttl'];
  268 + $expiresAt = date('Y-m-d H:i:s', time() + $ttl);
  269 +
  270 + // 单点登录:删除该用户的其他 session
  271 + if ($this->config['session']['single_login']) {
  272 + $stmt = $this->pdo->prepare("SELECT token FROM sessions WHERE user_id = ?");
  273 + $stmt->execute([$userId]);
  274 + $oldTokens = $stmt->fetchAll(\PDO::FETCH_COLUMN);
  275 +
  276 + // 删除 Redis 缓存
  277 + if ($this->redis && $oldTokens) {
  278 + foreach ($oldTokens as $oldToken) {
  279 + $this->redis->del("session:$oldToken");
  280 + }
  281 + }
  282 +
  283 + // 删除数据库记录
  284 + $stmt = $this->pdo->prepare("DELETE FROM sessions WHERE user_id = ?");
  285 + $stmt->execute([$userId]);
  286 + }
  287 +
  288 + // 插入新 session
  289 + $stmt = $this->pdo->prepare("
  290 + INSERT INTO sessions (user_id, token, expires_at)
  291 + VALUES (?, ?, ?)
  292 + ");
  293 + $stmt->execute([$userId, $token, $expiresAt]);
  294 +
  295 + return $token;
  296 + }
  297 +
  298 + /**
  299 + * 获取用户绑定的设备
  300 + */
  301 + private function getUserDevices($userId)
  302 + {
  303 + $stmt = $this->pdo->prepare("
  304 + SELECT d.id, d.name, d.status, d.last_seen, b.is_primary, b.bound_at
  305 + FROM user_device_bindings b
  306 + JOIN devices d ON b.device_id = d.id
  307 + WHERE b.user_id = ?
  308 + ORDER BY b.is_primary DESC, b.bound_at ASC
  309 + ");
  310 + $stmt->execute([$userId]);
  311 + return $stmt->fetchAll();
  312 + }
  313 +}
... ...
... ... @@ -5,6 +5,18 @@
5 5 */
6 6
7 7 return [
  8 + // 微信小程序配置
  9 + 'wechat' => [
  10 + 'app_id' => getenv('WECHAT_APP_ID') ?: '',
  11 + 'app_secret' => getenv('WECHAT_APP_SECRET') ?: '',
  12 + ],
  13 +
  14 + // Session 配置
  15 + 'session' => [
  16 + 'ttl' => (int) (getenv('SESSION_TTL') ?: 7 * 24 * 3600), // 7 天
  17 + 'single_login' => (bool) (getenv('SINGLE_LOGIN') ?: true), // 单点登录
  18 + ],
  19 +
8 20 // Redis 配置
9 21 'redis' => [
10 22 'host' => getenv('REDIS_HOST') ?: '127.0.0.1',
... ...
  1 +<?php
  2 +/**
  3 + * 设备管理模块
  4 + * 处理设备注册、绑定、状态管理
  5 + */
  6 +
  7 +class DeviceService
  8 +{
  9 + private $pdo;
  10 + private $config;
  11 +
  12 + public function __construct($pdo, $config)
  13 + {
  14 + $this->pdo = $pdo;
  15 + $this->config = $config;
  16 + }
  17 +
  18 + /**
  19 + * 绑定设备到用户
  20 + * @param int $userId 用户ID
  21 + * @param string $deviceId 设备ID
  22 + * @param string $deviceSecret 设备密钥 (可选验证)
  23 + * @return array
  24 + */
  25 + public function bindDevice($userId, $deviceId, $deviceSecret = null)
  26 + {
  27 + // 1. 检查设备是否存在
  28 + $stmt = $this->pdo->prepare("SELECT * FROM devices WHERE id = ?");
  29 + $stmt->execute([$deviceId]);
  30 + $device = $stmt->fetch();
  31 +
  32 + if (!$device) {
  33 + return ['ok' => false, 'error' => '设备不存在'];
  34 + }
  35 +
  36 + // 2. 验证设备密钥 (如果提供)
  37 + if ($deviceSecret !== null && $device['secret'] !== $deviceSecret) {
  38 + return ['ok' => false, 'error' => '设备密钥错误'];
  39 + }
  40 +
  41 + // 3. 检查是否已绑定
  42 + $stmt = $this->pdo->prepare("
  43 + SELECT * FROM user_device_bindings
  44 + WHERE user_id = ? AND device_id = ?
  45 + ");
  46 + $stmt->execute([$userId, $deviceId]);
  47 + if ($stmt->fetch()) {
  48 + return ['ok' => false, 'error' => '设备已绑定'];
  49 + }
  50 +
  51 + // 4. 检查用户是否有其他设备
  52 + $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM user_device_bindings WHERE user_id = ?");
  53 + $stmt->execute([$userId]);
  54 + $count = $stmt->fetchColumn();
  55 + $isPrimary = ($count == 0) ? 1 : 0;
  56 +
  57 + // 5. 创建绑定
  58 + $stmt = $this->pdo->prepare("
  59 + INSERT INTO user_device_bindings (user_id, device_id, is_primary)
  60 + VALUES (?, ?, ?)
  61 + ");
  62 + $stmt->execute([$userId, $deviceId, $isPrimary]);
  63 +
  64 + return [
  65 + 'ok' => true,
  66 + 'device' => [
  67 + 'id' => $device['id'],
  68 + 'name' => $device['name'],
  69 + 'status' => $device['status'],
  70 + 'is_primary' => (bool) $isPrimary,
  71 + ]
  72 + ];
  73 + }
  74 +
  75 + /**
  76 + * 解绑设备
  77 + * @param int $userId 用户ID
  78 + * @param string $deviceId 设备ID
  79 + * @return array
  80 + */
  81 + public function unbindDevice($userId, $deviceId)
  82 + {
  83 + $stmt = $this->pdo->prepare("
  84 + DELETE FROM user_device_bindings
  85 + WHERE user_id = ? AND device_id = ?
  86 + ");
  87 + $stmt->execute([$userId, $deviceId]);
  88 +
  89 + if ($stmt->rowCount() === 0) {
  90 + return ['ok' => false, 'error' => '绑定关系不存在'];
  91 + }
  92 +
  93 + return ['ok' => true];
  94 + }
  95 +
  96 + /**
  97 + * 获取用户的设备列表
  98 + * @param int $userId 用户ID
  99 + * @return array
  100 + */
  101 + public function getUserDevices($userId)
  102 + {
  103 + $stmt = $this->pdo->prepare("
  104 + SELECT d.id, d.name, d.status, d.last_seen, b.is_primary, b.bound_at
  105 + FROM user_device_bindings b
  106 + JOIN devices d ON b.device_id = d.id
  107 + WHERE b.user_id = ?
  108 + ORDER BY b.is_primary DESC, b.bound_at ASC
  109 + ");
  110 + $stmt->execute([$userId]);
  111 + return $stmt->fetchAll();
  112 + }
  113 +
  114 + /**
  115 + * 设置主设备
  116 + * @param int $userId 用户ID
  117 + * @param string $deviceId 设备ID
  118 + * @return array
  119 + */
  120 + public function setPrimaryDevice($userId, $deviceId)
  121 + {
  122 + // 先取消所有主设备
  123 + $stmt = $this->pdo->prepare("
  124 + UPDATE user_device_bindings SET is_primary = 0 WHERE user_id = ?
  125 + ");
  126 + $stmt->execute([$userId]);
  127 +
  128 + // 设置新主设备
  129 + $stmt = $this->pdo->prepare("
  130 + UPDATE user_device_bindings SET is_primary = 1
  131 + WHERE user_id = ? AND device_id = ?
  132 + ");
  133 + $stmt->execute([$userId, $deviceId]);
  134 +
  135 + if ($stmt->rowCount() === 0) {
  136 + return ['ok' => false, 'error' => '设备未绑定'];
  137 + }
  138 +
  139 + return ['ok' => true];
  140 + }
  141 +
  142 + /**
  143 + * 更新设备状态
  144 + * @param string $deviceId 设备ID
  145 + * @param string $status online/offline
  146 + * @return bool
  147 + */
  148 + public function updateDeviceStatus($deviceId, $status)
  149 + {
  150 + $stmt = $this->pdo->prepare("
  151 + UPDATE devices SET status = ?, last_seen = NOW() WHERE id = ?
  152 + ");
  153 + $stmt->execute([$status, $deviceId]);
  154 + return $stmt->rowCount() > 0;
  155 + }
  156 +
  157 + /**
  158 + * 注册新设备
  159 + * @param string $deviceId 设备ID
  160 + * @param string $secret 设备密钥
  161 + * @param string $name 设备名称
  162 + * @return array
  163 + */
  164 + public function registerDevice($deviceId, $secret, $name = null)
  165 + {
  166 + // 检查是否已存在
  167 + $stmt = $this->pdo->prepare("SELECT * FROM devices WHERE id = ?");
  168 + $stmt->execute([$deviceId]);
  169 + if ($stmt->fetch()) {
  170 + return ['ok' => false, 'error' => '设备ID已存在'];
  171 + }
  172 +
  173 + // 创建设备
  174 + $stmt = $this->pdo->prepare("
  175 + INSERT INTO devices (id, secret, name, status)
  176 + VALUES (?, ?, ?, 'offline')
  177 + ");
  178 + $stmt->execute([$deviceId, $secret, $name ?: '未命名设备']);
  179 +
  180 + return [
  181 + 'ok' => true,
  182 + 'device' => [
  183 + 'id' => $deviceId,
  184 + 'name' => $name ?: '未命名设备',
  185 + 'status' => 'offline',
  186 + ]
  187 + ];
  188 + }
  189 +
  190 + /**
  191 + * 验证设备连接
  192 + * @param string $deviceId 设备ID
  193 + * @param string $secret 设备密钥
  194 + * @return bool
  195 + */
  196 + public function verifyDevice($deviceId, $secret)
  197 + {
  198 + $stmt = $this->pdo->prepare("SELECT secret FROM devices WHERE id = ?");
  199 + $stmt->execute([$deviceId]);
  200 + $device = $stmt->fetch();
  201 +
  202 + if (!$device) {
  203 + return false;
  204 + }
  205 +
  206 + return $device['secret'] === $secret;
  207 + }
  208 +
  209 + /**
  210 + * 获取设备的绑定用户
  211 + * @param string $deviceId 设备ID
  212 + * @return array 用户ID列表
  213 + */
  214 + public function getDeviceUsers($deviceId)
  215 + {
  216 + $stmt = $this->pdo->prepare("
  217 + SELECT u.id, u.phone, u.nickname
  218 + FROM user_device_bindings b
  219 + JOIN users u ON b.user_id = u.id
  220 + WHERE b.device_id = ?
  221 + ");
  222 + $stmt->execute([$deviceId]);
  223 + return $stmt->fetchAll();
  224 + }
  225 +
  226 + /**
  227 + * 检查用户是否有权限访问设备
  228 + * @param int $userId 用户ID
  229 + * @param string $deviceId 设备ID
  230 + * @return bool
  231 + */
  232 + public function canAccessDevice($userId, $deviceId)
  233 + {
  234 + $stmt = $this->pdo->prepare("
  235 + SELECT 1 FROM user_device_bindings
  236 + WHERE user_id = ? AND device_id = ?
  237 + ");
  238 + $stmt->execute([$userId, $deviceId]);
  239 + return (bool) $stmt->fetch();
  240 + }
  241 +}
... ...
... ... @@ -33,30 +33,54 @@ CREATE TABLE IF NOT EXISTS `devices` (
33 33 `id` VARCHAR(64) NOT NULL COMMENT '设备ID (Device ID)',
34 34 `secret` VARCHAR(128) NOT NULL COMMENT '连接密钥 (Hash)',
35 35 `name` VARCHAR(50) DEFAULT NULL COMMENT '设备昵称',
36   - `owner_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '绑定用户ID (NULL=未绑定)',
37 36 `status` ENUM('online', 'offline') DEFAULT 'offline' COMMENT '在线状态',
38 37 `last_seen` TIMESTAMP NULL DEFAULT NULL COMMENT '最后心跳时间',
39 38 `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
40 39 `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
41   - PRIMARY KEY (`id`),
42   - KEY `idx_owner` (`owner_id`)
  40 + PRIMARY KEY (`id`)
43 41 ) ENGINE=InnoDB COMMENT='设备管理表';
44 42
45 43 -- Users Table
46 44 CREATE TABLE IF NOT EXISTS `users` (
47 45 `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
48 46 `phone` VARCHAR(20) NOT NULL COMMENT '手机号',
49   - `password` VARCHAR(255) NOT NULL COMMENT '密码 Hash',
50 47 `wx_openid` VARCHAR(64) DEFAULT NULL COMMENT '微信 OpenID',
  48 + `wx_unionid` VARCHAR(64) DEFAULT NULL COMMENT '微信 UnionID',
51 49 `nickname` VARCHAR(50) DEFAULT NULL COMMENT '显示的昵称',
52 50 `avatar_url` VARCHAR(255) DEFAULT NULL,
53 51 `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
54 52 `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
55 53 PRIMARY KEY (`id`),
56 54 UNIQUE KEY `uk_phone` (`phone`),
57   - UNIQUE KEY `uk_wx_openid` (`wx_openid`)
  55 + KEY `idx_wx_openid` (`wx_openid`)
58 56 ) ENGINE=InnoDB COMMENT='用户表';
59 57
  58 +-- Sessions Table (用户登录会话)
  59 +CREATE TABLE IF NOT EXISTS `sessions` (
  60 + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  61 + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
  62 + `token` VARCHAR(128) NOT NULL COMMENT 'Session Token',
  63 + `device_info` JSON DEFAULT NULL COMMENT '登录设备信息',
  64 + `expires_at` TIMESTAMP NOT NULL COMMENT '过期时间',
  65 + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  66 + PRIMARY KEY (`id`),
  67 + UNIQUE KEY `uk_token` (`token`),
  68 + KEY `idx_user` (`user_id`),
  69 + KEY `idx_expires` (`expires_at`)
  70 +) ENGINE=InnoDB COMMENT='用户会话表';
  71 +
  72 +-- User Device Bindings Table (手机号-设备绑定关系)
  73 +CREATE TABLE IF NOT EXISTS `user_device_bindings` (
  74 + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  75 + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
  76 + `device_id` VARCHAR(64) NOT NULL COMMENT '设备ID',
  77 + `is_primary` TINYINT(1) DEFAULT 0 COMMENT '是否主设备',
  78 + `bound_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
  79 + PRIMARY KEY (`id`),
  80 + UNIQUE KEY `uk_user_device` (`user_id`, `device_id`),
  81 + KEY `idx_device` (`device_id`)
  82 +) ENGINE=InnoDB COMMENT='用户设备绑定表';
  83 +
60 84 -- Device Logs Table
61 85 CREATE TABLE IF NOT EXISTS `device_logs` (
62 86 `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
... ...
... ... @@ -7,6 +7,8 @@ use Tos\Exception\TosServerException;
7 7 use Tos\Model\PutObjectInput;
8 8
9 9 require_once __DIR__ . '/vendor/autoload.php';
  10 +require_once __DIR__ . '/auth.php';
  11 +require_once __DIR__ . '/device.php';
10 12
11 13 // 加载配置文件
12 14 $config = require __DIR__ . '/config.php';
... ... @@ -14,14 +16,19 @@ $config = require __DIR__ . '/config.php';
14 16 // Define Heartbeat Interval
15 17 define('HEARTBEAT_TIME', $config['heartbeat']['interval']);
16 18
17   -// Create a WebSocket worker
18   -$ws_host = $config['server']['websocket_host'];
19   -$ws_port = $config['server']['websocket_port'];
20   -$ws_worker = new Worker("websocket://{$ws_host}:{$ws_port}");
21   -
22   -// Emulate simple routing/state for now
23   -// In production, use Redis for distributed state
24   -$ws_worker->count = $config['server']['worker_count'];
  19 +// Database Connection
  20 +$pdo = null;
  21 +try {
  22 + $db_config = $config['database'];
  23 + $dsn = "mysql:host={$db_config['host']};port={$db_config['port']};dbname={$db_config['database']};charset=utf8mb4";
  24 + $pdo = new PDO($dsn, $db_config['username'], $db_config['password'], [
  25 + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  26 + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
  27 + ]);
  28 + echo " Connected to MySQL at {$db_config['host']}:{$db_config['port']}\n";
  29 +} catch (Exception $e) {
  30 + echo "⚠️ MySQL Connection Failed: " . $e->getMessage() . "\n";
  31 +}
25 32
26 33 // Redis Connection
27 34 $redis = null;
... ... @@ -46,6 +53,15 @@ try {
46 53 echo "⚠️ Redis Connection Failed: " . $e->getMessage() . "\n";
47 54 }
48 55
  56 +// Initialize Services
  57 +$authService = $pdo ? new AuthService($pdo, $config, $redis) : null;
  58 +$deviceService = $pdo ? new DeviceService($pdo, $config) : null;
  59 +
  60 +// Create a WebSocket worker
  61 +$ws_host = $config['server']['websocket_host'];
  62 +$ws_port = $config['server']['websocket_port'];
  63 +$ws_worker = new Worker("websocket://{$ws_host}:{$ws_port}");
  64 +
49 65 // Store connections (Memory for now, can move to Redis later)
50 66 $clients = []; // ClientID -> Connection
51 67 $devices = []; // DeviceID -> Connection
... ... @@ -71,7 +87,7 @@ $ws_worker->onConnect = function ($connection) {
71 87 $connection->authVerified = false;
72 88 };
73 89
74   -$ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices) {
  90 +$ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices, $authService, $deviceService) {
75 91 $msg = json_decode($data, true);
76 92 if (!$msg || !isset($msg['type'])) {
77 93 return;
... ... @@ -80,24 +96,66 @@ $ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices)
80 96 // 1. Authenticate / Register
81 97 if ($msg['type'] === 'register') {
82 98 if ($msg['role'] === 'device') {
83   - // Device 注册
84   - $deviceId = $msg['id']; // TODO: Add secret validation
  99 + // Device 注册 - 验证密钥
  100 + $deviceId = $msg['id'] ?? null;
  101 + $secret = $msg['secret'] ?? null;
  102 +
  103 + if (!$deviceId) {
  104 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Device ID required']));
  105 + return;
  106 + }
  107 +
  108 + // 验证设备(如果有 deviceService)
  109 + if ($deviceService && $secret) {
  110 + if (!$deviceService->verifyDevice($deviceId, $secret)) {
  111 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid device credentials']));
  112 + $connection->close();
  113 + return;
  114 + }
  115 + // 更新设备状态为在线
  116 + $deviceService->updateDeviceStatus($deviceId, 'online');
  117 + }
  118 +
85 119 $devices[$deviceId] = $connection;
86 120 $connection->deviceId = $deviceId;
87 121 $connection->role = 'device';
88 122 $connection->authVerified = true;
89 123 $connection->send(json_encode(['type' => 'ack', 'status' => 'registered']));
90 124 echo "Device Registered: $deviceId\n";
  125 +
91 126 } elseif ($msg['role'] === 'client') {
92   - // Mini Program 注册
93   - // TODO: Validate Token
94   - $clientId = $msg['id'];
  127 + // Mini Program 注册 - 验证 Token
  128 + $clientId = $msg['id'] ?? null;
  129 + $token = $msg['token'] ?? null;
  130 +
  131 + if (!$clientId) {
  132 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Client ID required']));
  133 + return;
  134 + }
  135 +
  136 + // 验证 Token(如果有 authService 和 token)
  137 + $user = null;
  138 + if ($authService && $token) {
  139 + $user = $authService->verifyToken($token);
  140 + if (!$user) {
  141 + $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid or expired token']));
  142 + $connection->close();
  143 + return;
  144 + }
  145 + $connection->userId = $user['id'];
  146 + $connection->userPhone = $user['phone'];
  147 + }
  148 +
95 149 $clients[$clientId] = $connection;
96 150 $connection->clientId = $clientId;
97 151 $connection->role = 'client';
98 152 $connection->authVerified = true;
99   - $connection->send(json_encode(['type' => 'ack', 'status' => 'connected']));
100   - echo "Client Connected: $clientId\n";
  153 + $connection->send(json_encode([
  154 + 'type' => 'ack',
  155 + 'status' => 'connected',
  156 + 'user' => $user
  157 + ]));
  158 + echo "Client Connected: $clientId" . ($user ? " (User: {$user['phone']})" : "") . "\n";
101 159 }
102 160 return;
103 161 }
... ... @@ -112,12 +170,22 @@ $ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices)
112 170 // Client -> Device
113 171 if ($msg['type'] === 'proxy' && $connection->role === 'client') {
114 172 $targetDeviceId = $msg['targetDeviceId'] ?? null;
  173 +
  174 + // 验证用户是否有权限访问该设备
  175 + if ($deviceService && isset($connection->userId)) {
  176 + if (!$deviceService->canAccessDevice($connection->userId, $targetDeviceId)) {
  177 + $connection->send(json_encode(['type' => 'error', 'msg' => 'No permission to access this device']));
  178 + return;
  179 + }
  180 + }
  181 +
115 182 if ($targetDeviceId && isset($devices[$targetDeviceId])) {
116 183 $payload = $msg['payload'];
117 184 // Wrap it so device knows who sent it
118 185 $forwardMsg = [
119 186 'type' => 'cmd:execute',
120 187 'fromClientId' => $connection->clientId,
  188 + 'fromUserId' => $connection->userId ?? null,
121 189 'payload' => $payload
122 190 ];
123 191 $devices[$targetDeviceId]->send(json_encode($forwardMsg));
... ... @@ -143,10 +211,14 @@ $ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices)
143 211 }
144 212 };
145 213
146   -$ws_worker->onClose = function ($connection) use (&$clients, &$devices) {
  214 +$ws_worker->onClose = function ($connection) use (&$clients, &$devices, $deviceService) {
147 215 if (isset($connection->role)) {
148 216 if ($connection->role === 'device' && isset($connection->deviceId)) {
149 217 unset($devices[$connection->deviceId]);
  218 + // 更新设备状态为离线
  219 + if ($deviceService) {
  220 + $deviceService->updateDeviceStatus($connection->deviceId, 'offline');
  221 + }
150 222 echo "Device disconnected: {$connection->deviceId}\n";
151 223 } elseif ($connection->role === 'client' && isset($connection->clientId)) {
152 224 unset($clients[$connection->clientId]);
... ... @@ -162,9 +234,206 @@ $http_host = $config['server']['http_host'];
162 234 $http_port = $config['server']['http_port'];
163 235 $http_worker = new Worker("http://{$http_host}:{$http_port}");
164 236 $http_worker->count = $config['server']['worker_count'];
165   -$http_worker->onMessage = function ($connection, $request) use ($config) {
166   - // 1. Static File Serving (Simple implementation)
  237 +$http_worker->onMessage = function ($connection, $request) use ($config, $authService, $deviceService) {
167 238 $path = $request->path();
  239 + $method = $request->method();
  240 +
  241 + // Helper: JSON Response
  242 + $jsonResponse = function ($data, $status = 200) use ($connection) {
  243 + $connection->send(new \Workerman\Protocols\Http\Response(
  244 + $status,
  245 + ['Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*'],
  246 + json_encode($data)
  247 + ));
  248 + };
  249 +
  250 + // CORS Preflight
  251 + if ($method === 'OPTIONS') {
  252 + $connection->send(new \Workerman\Protocols\Http\Response(200, [
  253 + 'Access-Control-Allow-Origin' => '*',
  254 + 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
  255 + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
  256 + ], ''));
  257 + return;
  258 + }
  259 +
  260 + // ==================== Auth API ====================
  261 +
  262 + // POST /api/auth/login - 微信登录
  263 + if ($path === '/api/auth/login' && $method === 'POST') {
  264 + if (!$authService) {
  265 + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
  266 + return;
  267 + }
  268 +
  269 + $body = json_decode($request->rawBody(), true) ?: [];
  270 + $code = $body['code'] ?? null;
  271 + $phone = $body['phone'] ?? null;
  272 + $phoneCode = $body['phoneCode'] ?? null;
  273 +
  274 + if (!$code) {
  275 + $jsonResponse(['ok' => false, 'error' => 'code is required'], 400);
  276 + return;
  277 + }
  278 +
  279 + $result = $authService->wechatLogin($code, $phone, $phoneCode);
  280 + $jsonResponse($result, $result['ok'] ? 200 : 400);
  281 + return;
  282 + }
  283 +
  284 + // POST /api/auth/verify - 验证 Token
  285 + if ($path === '/api/auth/verify' && $method === 'POST') {
  286 + if (!$authService) {
  287 + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
  288 + return;
  289 + }
  290 +
  291 + $body = json_decode($request->rawBody(), true) ?: [];
  292 + $token = $body['token'] ?? null;
  293 +
  294 + if (!$token) {
  295 + $jsonResponse(['ok' => false, 'error' => 'token is required'], 400);
  296 + return;
  297 + }
  298 +
  299 + $user = $authService->verifyToken($token);
  300 + if ($user) {
  301 + $jsonResponse(['ok' => true, 'user' => $user]);
  302 + } else {
  303 + $jsonResponse(['ok' => false, 'error' => 'Invalid or expired token'], 401);
  304 + }
  305 + return;
  306 + }
  307 +
  308 + // POST /api/auth/logout - 注销
  309 + if ($path === '/api/auth/logout' && $method === 'POST') {
  310 + if (!$authService) {
  311 + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500);
  312 + return;
  313 + }
  314 +
  315 + $body = json_decode($request->rawBody(), true) ?: [];
  316 + $token = $body['token'] ?? null;
  317 +
  318 + if ($token) {
  319 + $authService->logout($token);
  320 + }
  321 + $jsonResponse(['ok' => true]);
  322 + return;
  323 + }
  324 +
  325 + // ==================== Device API ====================
  326 +
  327 + // Helper: Get user from token
  328 + $getUser = function () use ($request, $authService) {
  329 + $authHeader = $request->header('Authorization');
  330 + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
  331 + return null;
  332 + }
  333 + $token = substr($authHeader, 7);
  334 + return $authService ? $authService->verifyToken($token) : null;
  335 + };
  336 +
  337 + // POST /api/device/bind - 绑定设备
  338 + if ($path === '/api/device/bind' && $method === 'POST') {
  339 + if (!$deviceService) {
  340 + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
  341 + return;
  342 + }
  343 +
  344 + $user = $getUser();
  345 + if (!$user) {
  346 + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
  347 + return;
  348 + }
  349 +
  350 + $body = json_decode($request->rawBody(), true) ?: [];
  351 + $deviceId = $body['deviceId'] ?? null;
  352 + $deviceSecret = $body['deviceSecret'] ?? null;
  353 +
  354 + if (!$deviceId) {
  355 + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
  356 + return;
  357 + }
  358 +
  359 + $result = $deviceService->bindDevice($user['id'], $deviceId, $deviceSecret);
  360 + $jsonResponse($result, $result['ok'] ? 200 : 400);
  361 + return;
  362 + }
  363 +
  364 + // POST /api/device/unbind - 解绑设备
  365 + if ($path === '/api/device/unbind' && $method === 'POST') {
  366 + if (!$deviceService) {
  367 + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
  368 + return;
  369 + }
  370 +
  371 + $user = $getUser();
  372 + if (!$user) {
  373 + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
  374 + return;
  375 + }
  376 +
  377 + $body = json_decode($request->rawBody(), true) ?: [];
  378 + $deviceId = $body['deviceId'] ?? null;
  379 +
  380 + if (!$deviceId) {
  381 + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
  382 + return;
  383 + }
  384 +
  385 + $result = $deviceService->unbindDevice($user['id'], $deviceId);
  386 + $jsonResponse($result, $result['ok'] ? 200 : 400);
  387 + return;
  388 + }
  389 +
  390 + // GET /api/device/list - 获取绑定的设备列表
  391 + if ($path === '/api/device/list' && $method === 'GET') {
  392 + if (!$deviceService) {
  393 + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
  394 + return;
  395 + }
  396 +
  397 + $user = $getUser();
  398 + if (!$user) {
  399 + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
  400 + return;
  401 + }
  402 +
  403 + $devices = $deviceService->getUserDevices($user['id']);
  404 + $jsonResponse(['ok' => true, 'devices' => $devices]);
  405 + return;
  406 + }
  407 +
  408 + // POST /api/device/primary - 设置主设备
  409 + if ($path === '/api/device/primary' && $method === 'POST') {
  410 + if (!$deviceService) {
  411 + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500);
  412 + return;
  413 + }
  414 +
  415 + $user = $getUser();
  416 + if (!$user) {
  417 + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401);
  418 + return;
  419 + }
  420 +
  421 + $body = json_decode($request->rawBody(), true) ?: [];
  422 + $deviceId = $body['deviceId'] ?? null;
  423 +
  424 + if (!$deviceId) {
  425 + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400);
  426 + return;
  427 + }
  428 +
  429 + $result = $deviceService->setPrimaryDevice($user['id'], $deviceId);
  430 + $jsonResponse($result, $result['ok'] ? 200 : 400);
  431 + return;
  432 + }
  433 +
  434 + // ==================== File API ====================
  435 +
  436 + // 1. Static File Serving (Simple implementation)
168 437 if (strpos($path, '/uploads/') === 0) {
169 438 $file = __DIR__ . $path;
170 439 if (is_file($file)) {
... ...
Please register or login to post a comment