ensurePasswordColumn(); $this->ensureTokenTable(); $this->ensureSmsCodeTable(); } public function sendLoginCode(string $mobile, Request $request): array { $mobile = $this->normalizeMobile($mobile); $user = Db::name('users')->where('mobile', $mobile)->find(); if ($user && ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('账号已停用,无法发送验证码'); } $scene = 'login'; $now = time(); $latest = Db::name('sms_code_logs') ->where('mobile', $mobile) ->where('scene', $scene) ->order('id', 'desc') ->find(); if ($latest) { $retryAt = strtotime((string)$latest['created_at']) + 60; if ($retryAt > $now) { throw new \RuntimeException(sprintf('请 %d 秒后再试', max(1, $retryAt - $now))); } } $todayStart = date('Y-m-d 00:00:00'); $todayCount = (int)Db::name('sms_code_logs') ->where('mobile', $mobile) ->where('scene', $scene) ->where('created_at', '>=', $todayStart) ->count(); if ($todayCount >= 20) { throw new \RuntimeException('今日验证码发送次数已达上限,请明天再试'); } $code = (string)random_int(100000, 999999); $nowText = date('Y-m-d H:i:s', $now); $expireTime = date('Y-m-d H:i:s', $now + 300); $sendResult = null; $sendStatus = 'failed'; $failedReason = ''; try { $sendResult = (new AppSmsService())->sendLoginCode($mobile, $code); $sendStatus = ($sendResult['provider'] ?? '') === 'debug' ? 'mock' : 'success'; } catch (\Throwable $e) { $failedReason = $this->truncateText($e->getMessage(), 250); } Db::name('sms_code_logs')->insert([ 'mobile' => $mobile, 'scene' => $scene, 'code_hash' => $this->codeHash($mobile, $scene, $code), 'send_status' => $sendStatus, 'provider' => $sendResult['provider'] ?? 'aliyun_sms', 'template_code' => $this->systemConfig('sms', 'login_template_code'), 'request_id' => $sendResult['request_id'] ?? '', 'biz_id' => $sendResult['biz_id'] ?? '', 'failed_reason' => $failedReason, 'expire_time' => $expireTime, 'used_at' => null, 'send_ip' => $request->getRealIp(), 'created_at' => $nowText, 'updated_at' => $nowText, ]); if ($sendStatus === 'failed') { throw new \RuntimeException($failedReason ?: '验证码发送失败'); } $payload = [ 'mobile' => $mobile, 'scene' => $scene, 'expire_seconds' => 300, 'retry_after_seconds' => 60, ]; if (($sendResult['debug_code'] ?? null) !== null) { $payload['debug_code'] = $sendResult['debug_code']; } return $payload; } public function loginByCode(string $mobile, string $code, Request $request): array { $mobile = $this->normalizeMobile($mobile); $code = trim($code); if (!preg_match('/^\d{6}$/', $code)) { throw new \RuntimeException('验证码格式不正确'); } $record = Db::name('sms_code_logs') ->where('mobile', $mobile) ->where('scene', 'login') ->whereIn('send_status', ['success', 'mock']) ->whereNull('used_at') ->order('id', 'desc') ->find(); if (!$record) { throw new \RuntimeException('验证码不存在或已失效'); } if (strtotime((string)$record['expire_time']) < time()) { throw new \RuntimeException('验证码已过期,请重新获取'); } if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) { throw new \RuntimeException('验证码错误'); } $now = date('Y-m-d H:i:s'); Db::name('sms_code_logs')->where('id', $record['id'])->update([ 'used_at' => $now, 'updated_at' => $now, ]); $user = Db::name('users')->where('mobile', $mobile)->find(); if ($user && ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('账号已停用'); } if (!$user) { $userId = (int)Db::name('users')->insertGetId([ 'nickname' => '安心验用户' . substr($mobile, -4), 'avatar' => '', 'mobile' => $mobile, 'password' => '', 'status' => 'enabled', 'last_login_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); } else { $userId = (int)$user['id']; } $this->syncMobileAuth($userId, $mobile, $now); return $this->issueToken($userId, $request, 'sms_code'); } public function loginByPassword(string $mobile, string $password, Request $request): array { $mobile = $this->normalizeMobile($mobile); $password = trim($password); if ($password === '') { throw new \RuntimeException('密码不能为空'); } $user = Db::name('users')->where('mobile', $mobile)->find(); if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('账号不存在或已停用'); } $passwordHash = (string)($user['password'] ?? ''); if ($passwordHash === '') { throw new \RuntimeException('当前账号尚未设置登录密码,请使用验证码登录'); } if (!password_verify($password, $passwordHash)) { throw new \RuntimeException('手机号或密码错误'); } $this->syncMobileAuth((int)$user['id'], $mobile, date('Y-m-d H:i:s')); return $this->issueToken((int)$user['id'], $request, 'password'); } public function logout(Request $request): void { $token = $this->extractToken($request); if ($token === '') { return; } Db::name('user_api_tokens')->where('token_hash', hash('sha256', $token))->delete(); } public function current(Request $request): ?array { $token = $this->extractToken($request); if ($token === '') { return null; } $record = Db::name('user_api_tokens') ->where('token_hash', hash('sha256', $token)) ->find(); if (!$record) { return null; } if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) { Db::name('user_api_tokens')->where('id', $record['id'])->delete(); return null; } $user = Db::name('users')->where('id', $record['user_id'])->find(); if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { return null; } Db::name('user_api_tokens')->where('id', $record['id'])->update([ 'last_active_at' => date('Y-m-d H:i:s'), 'last_ip' => $request->getRealIp(), 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), 'updated_at' => date('Y-m-d H:i:s'), ]); return $this->userInfo((int)$user['id']); } public function savePassword(int $userId, string $currentPassword, string $newPassword): array { $user = Db::name('users')->where('id', $userId)->find(); if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('账号不存在或已停用'); } $currentHash = (string)($user['password'] ?? ''); $hadPassword = $currentHash !== ''; if ($currentHash !== '') { if ($currentPassword === '') { throw new \RuntimeException('请输入当前密码'); } if (!password_verify($currentPassword, $currentHash)) { throw new \RuntimeException('当前密码错误'); } } $this->validateNewPassword($newPassword); Db::name('users')->where('id', $userId)->update([ 'password' => password_hash($newPassword, PASSWORD_BCRYPT), 'updated_at' => date('Y-m-d H:i:s'), ]); return [ 'user_id' => $userId, 'password_set' => true, 'had_password' => $hadPassword, ]; } private function issueToken(int $userId, Request $request, string $authType): array { $user = Db::name('users')->where('id', $userId)->find(); if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('账号不存在或已停用'); } $token = bin2hex(random_bytes(24)); $tokenHash = hash('sha256', $token); $now = date('Y-m-d H:i:s'); $expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600); Db::name('user_api_tokens')->where('user_id', $userId)->delete(); Db::name('user_api_tokens')->insert([ 'user_id' => $userId, 'token_hash' => $tokenHash, 'auth_type' => $authType, 'expire_time' => $expireTime, 'last_active_at' => $now, 'last_ip' => $request->getRealIp(), 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), 'created_at' => $now, 'updated_at' => $now, ]); Db::name('users')->where('id', $userId)->update([ 'last_login_at' => $now, 'updated_at' => $now, ]); return [ 'token' => $token, 'user_info' => $this->userInfo($userId), ]; } private function userInfo(int $userId): array { $user = Db::name('users')->where('id', $userId)->find(); return [ 'id' => (int)($user['id'] ?? 0), 'nickname' => $user['nickname'] ?: '安心验用户', 'mobile' => $user['mobile'] ?? '', 'avatar' => $user['avatar'] ?? '', 'status' => $user['status'] ?? 'enabled', 'password_set' => ((string)($user['password'] ?? '')) !== '', ]; } private function syncMobileAuth(int $userId, string $mobile, string $now): void { $existing = Db::name('user_auths') ->where('auth_type', 'mobile') ->where('auth_key', $mobile) ->find(); $payload = [ 'user_id' => $userId, 'auth_type' => 'mobile', 'auth_key' => $mobile, 'auth_open_id' => '', 'auth_union_id' => '', 'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE), 'updated_at' => $now, ]; if ($existing) { Db::name('user_auths')->where('id', $existing['id'])->update($payload); return; } $payload['created_at'] = $now; Db::name('user_auths')->insert($payload); } private function normalizeMobile(string $mobile): string { $mobile = preg_replace('/\D+/', '', $mobile) ?: ''; if (!preg_match('/^1\d{10}$/', $mobile)) { throw new \RuntimeException('请输入正确的手机号'); } return $mobile; } private function validateNewPassword(string $password): void { if (mb_strlen($password) < 8) { throw new \RuntimeException('密码长度不能少于 8 位'); } if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password)) { throw new \RuntimeException('密码需同时包含字母和数字'); } } private function codeHash(string $mobile, string $scene, string $code): string { return hash('sha256', implode('|', [$mobile, $scene, $code])); } private function systemConfig(string $groupCode, string $configKey): string { $row = Db::name('system_configs') ->where('config_group', $groupCode) ->where('config_key', $configKey) ->find(); return trim((string)($row['config_value'] ?? '')); } private function extractToken(Request $request): string { $authorization = trim((string)$request->header('authorization', '')); if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) { return trim($matches[1]); } return ''; } private function ensurePasswordColumn(): void { $column = Db::query("SHOW COLUMNS FROM users LIKE 'password'"); if ($column) { return; } Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile"); } private function ensureTokenTable(): void { Db::execute(<<<'SQL' CREATE TABLE IF NOT EXISTS user_api_tokens ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, token_hash VARCHAR(64) NOT NULL, auth_type VARCHAR(32) NOT NULL DEFAULT 'password', expire_time DATETIME NOT NULL, last_active_at DATETIME NULL DEFAULT NULL, last_ip VARCHAR(64) NOT NULL DEFAULT '', user_agent VARCHAR(500) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_api_tokens_token_hash (token_hash), KEY idx_user_api_tokens_user_id (user_id), KEY idx_user_api_tokens_expire_time (expire_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token'; SQL); } private function ensureSmsCodeTable(): void { Db::execute(<<<'SQL' CREATE TABLE IF NOT EXISTS sms_code_logs ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, mobile VARCHAR(32) NOT NULL, scene VARCHAR(32) NOT NULL DEFAULT 'login', code_hash VARCHAR(64) NOT NULL, send_status VARCHAR(32) NOT NULL DEFAULT 'success', provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms', template_code VARCHAR(64) NOT NULL DEFAULT '', request_id VARCHAR(128) NOT NULL DEFAULT '', biz_id VARCHAR(128) NOT NULL DEFAULT '', failed_reason VARCHAR(255) NOT NULL DEFAULT '', expire_time DATETIME NOT NULL, used_at DATETIME NULL DEFAULT NULL, send_ip VARCHAR(64) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_sms_code_logs_mobile_scene (mobile, scene), KEY idx_sms_code_logs_expire_time (expire_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录'; SQL); } private function truncateText(string $value, int $maxLength): string { if (mb_strlen($value) <= $maxLength) { return $value; } return mb_substr($value, 0, $maxLength); } }