ensurePasswordColumn(); $this->ensureUserAuthsTable(); $this->ensureTokenTable(); $this->ensureSmsCodeTable(); } public function wechatConfig(): array { $appId = $this->systemConfig('h5', 'app_id'); $appSecret = $this->systemConfig('h5', 'app_secret'); $redirectUrl = $this->resolveH5OAuthRedirectUrl(); return [ 'appid' => $appId, 'oauth_redirect_url' => $redirectUrl, 'enabled' => $appId !== '' && $appSecret !== '' && $redirectUrl !== '', 'scope' => 'snsapi_userinfo', 'state' => $this->createWechatOAuthState(), ]; } 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); $now = date('Y-m-d H:i:s'); $this->verifyLoginCode($mobile, $code, $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 exchangeWechatCode(string $code, string $state, Request $request): array { $code = trim($code); if ($code === '') { throw new \RuntimeException('微信授权 code 不能为空'); } $this->verifyWechatOAuthState($state); $config = $this->wechatConfig(); if (!$config['enabled']) { throw new \RuntimeException('微信授权登录未启用,请先在后台补全 H5 公众号配置和页面根地址'); } $oauthPayload = $this->fetchWechatOAuthAccessToken($code); $profilePayload = $this->fetchWechatUserInfoIfPossible($oauthPayload); $identity = $this->buildWechatIdentity($oauthPayload, $profilePayload, $state); $auth = $this->findWechatAuth($identity['openid'], $identity['unionid']); if ($auth) { $user = Db::name('users')->where('id', $auth['user_id'])->find(); if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('微信已绑定账号不存在或已停用'); } $this->syncWechatAuth((int)$auth['user_id'], $identity, date('Y-m-d H:i:s'), (int)$auth['id']); return array_merge([ 'status' => 'logged_in', ], $this->issueToken((int)$auth['user_id'], $request, self::WECHAT_H5_AUTH_TYPE)); } return [ 'status' => 'need_bind', 'bind_ticket' => $this->createWechatBindTicket($identity), 'expire_seconds' => self::WECHAT_BIND_TICKET_TTL, 'profile' => [ 'nickname' => $identity['nickname'], 'avatar' => $identity['avatar'], ], ]; } public function bindWechatMobile(string $bindTicket, string $mobile, string $code, Request $request): array { $identity = $this->verifyWechatBindTicket($bindTicket); $mobile = $this->normalizeMobile($mobile); $now = date('Y-m-d H:i:s'); Db::startTrans(); try { $user = Db::name('users')->where('mobile', $mobile)->lock(true)->find(); if ($user && ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('账号已停用,无法绑定微信'); } $this->assertWechatIdentityAvailable($identity, $user ? (int)$user['id'] : null); $this->verifyLoginCode($mobile, $code, $now); if (!$user) { $userId = (int)Db::name('users')->insertGetId([ 'nickname' => $identity['nickname'] !== '' ? $this->truncateText($identity['nickname'], 64) : '安心验用户' . substr($mobile, -4), 'avatar' => $this->truncateText($identity['avatar'], 255), 'mobile' => $mobile, 'password' => '', 'status' => 'enabled', 'last_login_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); } else { $userId = (int)$user['id']; $profilePatch = $this->buildWechatProfilePatch($user, $identity, $now); if ($profilePatch) { Db::name('users')->where('id', $userId)->update($profilePatch); } } $this->syncMobileAuth($userId, $mobile, $now); $this->syncWechatAuth($userId, $identity, $now); $payload = array_merge([ 'status' => 'logged_in', ], $this->issueToken($userId, $request, self::WECHAT_H5_AUTH_TYPE)); Db::commit(); return $payload; } catch (\Throwable $e) { Db::rollback(); throw $e; } } 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 verifyLoginCode(string $mobile, string $code, ?string $now = null): void { $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('验证码错误'); } if ($now === null) { $now = date('Y-m-d H:i:s'); } Db::name('sms_code_logs')->where('id', $record['id'])->update([ 'used_at' => $now, 'updated_at' => $now, ]); } 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 syncWechatAuth(int $userId, array $identity, string $now, ?int $preferredAuthId = null): void { $openid = (string)($identity['openid'] ?? ''); $unionid = (string)($identity['unionid'] ?? ''); if ($openid === '') { throw new \RuntimeException('微信 openid 不能为空'); } $existing = null; if ($preferredAuthId) { $existing = Db::name('user_auths')->where('id', $preferredAuthId)->find(); } if (!$existing) { $existing = Db::name('user_auths') ->where('auth_type', self::WECHAT_H5_AUTH_TYPE) ->where('auth_key', $openid) ->find(); } if ($existing && (int)$existing['user_id'] !== $userId) { throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员'); } if ($unionid !== '') { $unionAuth = Db::name('user_auths') ->where('auth_type', self::WECHAT_H5_AUTH_TYPE) ->where('auth_union_id', $unionid) ->find(); if ($unionAuth && (int)$unionAuth['user_id'] !== $userId) { throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员'); } if (!$existing && $unionAuth) { $existing = $unionAuth; } } $payload = [ 'user_id' => $userId, 'auth_type' => self::WECHAT_H5_AUTH_TYPE, 'auth_key' => $openid, 'auth_open_id' => $openid, 'auth_union_id' => $unionid, 'auth_extra' => json_encode($identity['auth_extra'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), '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 assertWechatIdentityAvailable(array $identity, ?int $allowedUserId): void { $openid = (string)($identity['openid'] ?? ''); $unionid = (string)($identity['unionid'] ?? ''); $openidAuth = Db::name('user_auths') ->where('auth_type', self::WECHAT_H5_AUTH_TYPE) ->where('auth_key', $openid) ->lock(true) ->find(); if ($openidAuth && ($allowedUserId === null || (int)$openidAuth['user_id'] !== $allowedUserId)) { throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员'); } if ($unionid === '') { return; } $unionAuth = Db::name('user_auths') ->where('auth_type', self::WECHAT_H5_AUTH_TYPE) ->where('auth_union_id', $unionid) ->lock(true) ->find(); if ($unionAuth && ($allowedUserId === null || (int)$unionAuth['user_id'] !== $allowedUserId)) { throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员'); } } private function findWechatAuth(string $openid, string $unionid): ?array { $auth = Db::name('user_auths') ->where('auth_type', self::WECHAT_H5_AUTH_TYPE) ->where('auth_key', $openid) ->find(); if ($auth) { return $auth; } if ($unionid === '') { return null; } $auth = Db::name('user_auths') ->where('auth_type', self::WECHAT_H5_AUTH_TYPE) ->where('auth_union_id', $unionid) ->order('id', 'asc') ->find(); return $auth ?: null; } private function buildWechatProfilePatch(array $user, array $identity, string $now): array { $patch = []; $nickname = trim((string)($identity['nickname'] ?? '')); $avatar = trim((string)($identity['avatar'] ?? '')); $currentNickname = trim((string)($user['nickname'] ?? '')); $currentAvatar = trim((string)($user['avatar'] ?? '')); if ($nickname !== '' && ($currentNickname === '' || preg_match('/^安心验用户\d{4}$/u', $currentNickname))) { $patch['nickname'] = $this->truncateText($nickname, 64); } if ($avatar !== '' && $currentAvatar === '') { $patch['avatar'] = $this->truncateText($avatar, 255); } if ($patch) { $patch['updated_at'] = $now; } return $patch; } private function fetchWechatOAuthAccessToken(string $code): array { if ($this->isWechatMockCode($code)) { return $this->mockWechatOAuthPayload($code); } $appId = $this->systemConfig('h5', 'app_id'); $appSecret = $this->systemConfig('h5', 'app_secret'); $url = 'https://api.weixin.qq.com/sns/oauth2/access_token?' . http_build_query([ 'appid' => $appId, 'secret' => $appSecret, 'code' => $code, 'grant_type' => 'authorization_code', ]); return $this->wechatApiGet($url, '微信授权 code 换取失败'); } private function fetchWechatUserInfoIfPossible(array $oauthPayload): array { $scope = (string)($oauthPayload['scope'] ?? ''); $accessToken = (string)($oauthPayload['access_token'] ?? ''); $openid = (string)($oauthPayload['openid'] ?? ''); if ($openid === '' || $accessToken === '' || strpos($scope, 'snsapi_userinfo') === false) { return []; } if (strpos($accessToken, 'mock_access_token_') === 0) { return $this->mockWechatProfilePayload($oauthPayload); } $url = 'https://api.weixin.qq.com/sns/userinfo?' . http_build_query([ 'access_token' => $accessToken, 'openid' => $openid, 'lang' => 'zh_CN', ]); try { return $this->wechatApiGet($url, '微信用户资料获取失败'); } catch (\RuntimeException $e) { return []; } } private function buildWechatIdentity(array $oauthPayload, array $profilePayload, string $state): array { $openid = trim((string)($oauthPayload['openid'] ?? $profilePayload['openid'] ?? '')); if ($openid === '') { throw new \RuntimeException('微信授权返回缺少 openid,请重新登录'); } $unionid = trim((string)($profilePayload['unionid'] ?? $oauthPayload['unionid'] ?? '')); $nickname = trim((string)($profilePayload['nickname'] ?? '')); $avatar = trim((string)($profilePayload['headimgurl'] ?? '')); return [ 'openid' => $openid, 'unionid' => $unionid, 'nickname' => $nickname, 'avatar' => $avatar, 'auth_extra' => [ 'oauth' => $this->redactWechatOAuthPayload($oauthPayload), 'profile' => $profilePayload, 'state' => $this->truncateText($state, 128), 'authorized_at' => date('Y-m-d H:i:s'), ], ]; } private function createWechatBindTicket(array $identity): string { $now = time(); $payload = [ 'typ' => 'wechat_h5_bind', 'openid' => (string)$identity['openid'], 'unionid' => (string)($identity['unionid'] ?? ''), 'nickname' => $this->truncateText((string)($identity['nickname'] ?? ''), 64), 'avatar' => $this->truncateText((string)($identity['avatar'] ?? ''), 255), 'auth_extra' => $identity['auth_extra'] ?? [], 'iat' => $now, 'exp' => $now + self::WECHAT_BIND_TICKET_TTL, 'nonce' => bin2hex(random_bytes(8)), ]; $body = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $signature = hash_hmac('sha256', $body, $this->ticketSecret()); return $body . '.' . $signature; } private function createWechatOAuthState(): string { $expiresAt = str_pad(base_convert((string)(time() + 600), 10, 36), 8, '0', STR_PAD_LEFT); $base = 'w' . $expiresAt . bin2hex(random_bytes(4)); $signature = substr(hash_hmac('sha256', $base, $this->ticketSecret()), 0, 20); return $base . $signature; } private function verifyWechatOAuthState(string $state): void { $state = trim($state); if (!preg_match('/^w[a-z0-9]{36}$/i', $state)) { throw new \RuntimeException('微信授权状态不匹配,请重新登录'); } $base = substr($state, 0, 17); $signature = substr($state, 17); $expected = substr(hash_hmac('sha256', $base, $this->ticketSecret()), 0, 20); if (!hash_equals($expected, strtolower($signature))) { throw new \RuntimeException('微信授权状态不匹配,请重新登录'); } $expiresAt = (int)base_convert(substr($state, 1, 8), 36, 10); if ($expiresAt < time()) { throw new \RuntimeException('微信授权状态已过期,请重新登录'); } } private function verifyWechatBindTicket(string $ticket): array { $ticket = trim($ticket); if ($ticket === '' || strpos($ticket, '.') === false) { throw new \RuntimeException('微信绑定凭证无效,请重新授权'); } [$body, $signature] = explode('.', $ticket, 2); $expected = hash_hmac('sha256', $body, $this->ticketSecret()); if (!hash_equals($expected, strtolower($signature))) { throw new \RuntimeException('微信绑定凭证签名无效,请重新授权'); } $decoded = json_decode($this->base64UrlDecode($body), true); if (!is_array($decoded) || ($decoded['typ'] ?? '') !== 'wechat_h5_bind') { throw new \RuntimeException('微信绑定凭证无效,请重新授权'); } if ((int)($decoded['exp'] ?? 0) < time()) { throw new \RuntimeException('微信绑定凭证已过期,请重新授权'); } $openid = trim((string)($decoded['openid'] ?? '')); if ($openid === '') { throw new \RuntimeException('微信绑定凭证缺少 openid,请重新授权'); } return [ 'openid' => $openid, 'unionid' => trim((string)($decoded['unionid'] ?? '')), 'nickname' => trim((string)($decoded['nickname'] ?? '')), 'avatar' => trim((string)($decoded['avatar'] ?? '')), 'auth_extra' => is_array($decoded['auth_extra'] ?? null) ? $decoded['auth_extra'] : [], ]; } private function wechatApiGet(string $url, string $fallbackMessage): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 4, ]); $response = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { throw new \RuntimeException($fallbackMessage . ':' . $error); } if ($httpStatus < 200 || $httpStatus >= 300) { throw new \RuntimeException($fallbackMessage . ':微信接口 HTTP ' . $httpStatus); } $payload = json_decode((string)$response, true); if (!is_array($payload)) { throw new \RuntimeException($fallbackMessage . ':微信接口返回异常'); } $errcode = (int)($payload['errcode'] ?? 0); if ($errcode !== 0) { throw new \RuntimeException($this->wechatErrorMessage($errcode, (string)($payload['errmsg'] ?? ''), $fallbackMessage)); } return $payload; } private function wechatErrorMessage(int $errcode, string $errmsg, string $fallbackMessage): string { $messages = [ 40029 => '微信授权 code 无效或已过期,请重新登录', 40163 => '微信授权 code 已被使用,请重新发起授权', 40013 => 'H5 公众号 AppID 无效,请检查后台配置', 40125 => 'H5 公众号 AppSecret 无效,请检查后台配置', ]; return $messages[$errcode] ?? ($fallbackMessage . ($errmsg !== '' ? ':' . $errmsg : '')); } private function redactWechatOAuthPayload(array $payload): array { unset($payload['access_token'], $payload['refresh_token']); return $payload; } private function isWechatMockCode(string $code): bool { if (strpos($code, 'mock_') !== 0) { return false; } return in_array(strtolower((string)($_ENV['WECHAT_H5_AUTH_MOCK'] ?? '')), ['1', 'true', 'yes'], true) || in_array(strtolower((string)($_ENV['APP_DEBUG'] ?? 'false')), ['1', 'true'], true); } private function mockWechatOAuthPayload(string $code): array { if (strpos($code, 'expired') !== false || strpos($code, 'invalid') !== false) { throw new \RuntimeException('微信授权 code 无效或已过期,请重新登录'); } $suffix = preg_replace('/[^A-Za-z0-9]/', '', substr($code, 5)) ?: 'user'; return [ 'access_token' => 'mock_access_token_' . $suffix, 'expires_in' => 7200, 'refresh_token' => 'mock_refresh_token_' . $suffix, 'openid' => 'mock_openid_' . $suffix, 'scope' => 'snsapi_userinfo', 'unionid' => 'mock_unionid_' . $suffix, ]; } private function mockWechatProfilePayload(array $oauthPayload): array { $openid = (string)($oauthPayload['openid'] ?? ''); $suffix = str_replace('mock_openid_', '', $openid) ?: 'user'; return [ 'openid' => $openid, 'nickname' => '微信用户' . $suffix, 'headimgurl' => 'https://thirdwx.qlogo.cn/mmopen/mock/' . rawurlencode($suffix) . '/132', 'privilege' => [], 'unionid' => (string)($oauthPayload['unionid'] ?? ''), ]; } 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 ticketSecret(): string { $seed = trim((string)($_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? '')); if ($seed === '') { $seed = $this->systemConfig('h5', 'app_secret'); } if ($seed === '') { $seed = 'anxinyan-app-auth-secret-key'; } return hash('sha256', $seed, true); } private function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private function base64UrlDecode(string $value): string { $padding = strlen($value) % 4; if ($padding > 0) { $value .= str_repeat('=', 4 - $padding); } $decoded = base64_decode(strtr($value, '-_', '+/'), true); return is_string($decoded) ? $decoded : ''; } 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 resolveH5OAuthRedirectUrl(): string { $pageBaseUrl = $this->normalizeH5PageBaseUrl($this->systemConfig('h5', 'page_base_url')); if ($pageBaseUrl !== '') { return $pageBaseUrl . self::H5_OAUTH_REDIRECT_HASH_PATH; } return $this->systemConfig('h5', 'oauth_redirect_url'); } private function normalizeH5PageBaseUrl(string $value): string { $baseUrl = trim($value); if ($baseUrl === '') { return ''; } $hashPos = strpos($baseUrl, '#'); if ($hashPos !== false) { $baseUrl = substr($baseUrl, 0, $hashPos); } if (!preg_match('/^https?:\/\//i', $baseUrl)) { $baseUrl = 'https://' . ltrim($baseUrl, '/'); } return rtrim($baseUrl, '/'); } 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 ensureUserAuthsTable(): void { Db::execute(<<<'SQL' CREATE TABLE IF NOT EXISTS user_auths ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, auth_type VARCHAR(32) NOT NULL, auth_open_id VARCHAR(128) NOT NULL DEFAULT '', auth_union_id VARCHAR(128) NOT NULL DEFAULT '', auth_key VARCHAR(128) NOT NULL DEFAULT '', auth_extra JSON NULL, 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_auths_type_key (auth_type, auth_key), KEY idx_user_auths_user_id (user_id), KEY idx_user_auths_auth_union_id (auth_type, auth_union_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射'; SQL); $index = Db::query("SHOW INDEX FROM user_auths WHERE Key_name = 'idx_user_auths_auth_union_id'"); if (!$index) { Db::execute("ALTER TABLE user_auths ADD KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)"); } } 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); } }