data.publicKey(RSA 公钥,Base64/X.509) * 2) 用公钥 RSA/ECB/PKCS1Padding 加密密码并 Base64 * 3) POST {base}{prefix}/public/login -> 登录成功后在 Set-Cookie 里下发 sk-token(JWT) * 注意:v1.13 服务端只从 Cookie 的 sk-token 取令牌,不读 Authorization 头 * (响应头也会回显 Authorization,但被服务端忽略) * 兼容旧版 / 企业版: * POST {prefix}/system/auth/login -> data.accessToken * POST /admin-api/oauth2/token -> data.access_token */ declare(strict_types=1); class SurveyKingClient { /** 社区版会话 Cookie 名称(AppConsts.TOKEN_NAME) */ public const TOKEN_COOKIE = 'sk-token'; private array $cfg; private ?string $token = null; private string $tokenFile; private bool $retried = false; /** Cookie 会话:社区版 v1.13 的服务端只认 Cookie 里的 sk-token,不读 Authorization 头 */ private array $cookies = []; private int $expireAt = 0; private string $loginVia = ''; /** @var array */ public array $log = []; public function __construct(array $cfg) { $this->cfg = $cfg; $this->tokenFile = NY_DATA . '/cache/token.json'; } /** 会话信息(用于诊断) */ public function sessionInfo(): array { return [ 'has_token' => $this->token !== null && $this->token !== '', 'token_head' => $this->token !== null ? substr($this->token, 0, 14) . '…' : '', 'cookies' => array_keys($this->cookies), 'login_via' => $this->loginVia, 'expire_at' => $this->expireAt > 0 ? date('Y-m-d H:i:s', $this->expireAt) : '', ]; } public function setConfig(array $cfg): void { $this->cfg = $cfg; } // ---------------- 地址 ---------------- public function base(): string { return rtrim((string)$this->cfg['base_url'], '/'); } public function apiBase(): string { return $this->base() . '/' . trim((string)$this->cfg['api_prefix'], '/'); } private function timeout(): int { return max(3, (int)($this->cfg['timeout'] ?? 20)); } private function cookieHeader(): string { $parts = []; foreach ($this->cookies as $k => $v) { $parts[] = $k . '=' . $v; } return implode('; ', $parts); } private function persistSession(): void { if ($this->token === null || $this->token === '') { return; } ny_write_json($this->tokenFile, [ 'token' => $this->token, 'expire_at' => $this->expireAt, 'cookies' => $this->cookies, 'login_via' => $this->loginVia, ]); } // ---------------- 底层 HTTP ---------------- /** * @return array{status:int,headers:array,data:mixed,raw:string,code:int,msg:string} */ private function raw(string $path, array $query = [], string $method = 'GET', mixed $body = null): array { if (!function_exists('curl_init')) { throw new RuntimeException('PHP 未启用 curl 扩展,无法访问卷王接口'); } $url = $this->apiBase() . $path; if ($query !== []) { $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query); } $headers = ['Accept: application/json', 'Accept-Charset: utf-8']; if ($method === 'POST') { $headers[] = 'Content-Type: application/json; charset=utf-8'; } // 令牌两种都发:老版本读 Authorization 头,v1.13 只读 sk-token Cookie if ($this->token !== null && $this->token !== '') { $headers[] = 'Authorization: Bearer ' . $this->token; } if ($this->cookies !== []) { $headers[] = 'Cookie: ' . $this->cookieHeader(); } $verify = !empty($this->cfg['verify_ssl']); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $this->timeout(), CURLOPT_CONNECTTIMEOUT => min(10, $this->timeout()), CURLOPT_SSL_VERIFYPEER => $verify, CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, CURLOPT_FOLLOWLOCATION => true, CURLOPT_ENCODING => '', CURLOPT_PROXY => self::proxyFor($url), ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE)); } $resp = curl_exec($ch); $err = curl_error($ch); $info = curl_getinfo($ch); curl_close($ch); if ($resp === false) { throw new RuntimeException('请求失败:' . $url . ' —— ' . $err); } $headerSize = (int)($info['header_size'] ?? 0); $rawHeader = substr((string)$resp, 0, $headerSize); $rawBody = substr((string)$resp, $headerSize); $status = (int)($info['http_code'] ?? 0); $h = []; foreach (preg_split('/\r?\n/', $rawHeader) ?: [] as $line) { if (str_contains($line, ':')) { [$k, $v] = explode(':', $line, 2); $h[strtolower(trim($k))] = trim($v); } } // 收集 Set-Cookie(可能有多个,上面的解析只保留最后一个) if (preg_match_all('/^Set-Cookie:\s*(.+)$/mi', $rawHeader, $sc) > 0) { foreach ($sc[1] as $one) { $pair = trim(explode(';', trim($one))[0]); $eq = strpos($pair, '='); if ($pair === '' || $eq === false) { continue; } $name = trim(substr($pair, 0, $eq)); $val = trim(substr($pair, $eq + 1)); if ($val === '' || strtolower($val) === 'deleted' || strtolower($val) === '""') { unset($this->cookies[$name]); // 服务端主动清除 continue; } $this->cookies[$name] = $val; } $this->persistSession(); } $decoded = json_decode($rawBody, true); $data = $decoded; $code = 0; $msg = ''; if (is_array($decoded) && array_key_exists('code', $decoded)) { $code = (int)$decoded['code']; $data = $decoded['data'] ?? null; $msg = (string)($decoded['msg'] ?? $decoded['message'] ?? ''); } return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawBody, 'code' => $code, 'msg' => $msg]; } /** * 代理设置:本机/内网地址不走系统代理(否则同机部署时会连不上卷王) */ private static function proxyFor(string $url): string { $host = parse_url($url, PHP_URL_HOST); if (!is_string($host) || $host === '') { return ''; } $host = strtolower(trim($host, '[]')); $isLoopback = in_array($host, ['localhost', '::1', '127.0.0.1'], true) || str_starts_with($host, '127.') || str_ends_with($host, '.localhost'); return $isLoopback ? '' : (string)(getenv('http_proxy') ?: ''); } /** * 带鉴权与 401 自动重登的请求 */ public function request(string $path, array $query = [], string $method = 'GET', mixed $body = null): array { $this->getToken(); $r = $this->raw($path, $query, $method, $body); $needRelogin = in_array($r['status'], [401, 403], true) || in_array($r['code'], [401, 403], true); if ($needRelogin && !$this->retried) { $this->retried = true; try { $this->login(true); $r = $this->raw($path, $query, $method, $body); } finally { $this->retried = false; } } if ($r['status'] >= 400 && $r['status'] !== 401 && $r['status'] !== 403) { throw new RuntimeException('接口 ' . $path . ' 返回 HTTP ' . $r['status'] . ':' . mb_substr($r['raw'], 0, 300)); } if ($r['code'] !== 0 && $r['code'] !== 200) { throw new RuntimeException('接口 ' . $path . ' 返回错误:' . ($r['msg'] !== '' ? $r['msg'] : 'code=' . $r['code'])); } return $r; } // ---------------- 登录 ---------------- public function getToken(bool $force = false): string { if (!$force && $this->token !== null && $this->token !== '') { return $this->token; } if (!$force) { $c = ny_read_json($this->tokenFile, null); if (is_array($c) && !empty($c['token']) && (int)($c['expire_at'] ?? 0) > time() + 60) { $this->token = (string)$c['token']; $this->expireAt = (int)($c['expire_at'] ?? 0); $this->cookies = is_array($c['cookies'] ?? null) ? $c['cookies'] : []; $this->loginVia = (string)($c['login_via'] ?? ''); return $this->token; } } $this->login($force); return (string)$this->token; } public function login(bool $force = false): string { $this->token = null; $this->cookies = []; $this->expireAt = 0; $this->loginVia = ''; $username = (string)($this->cfg['username'] ?? ''); $password = (string)($this->cfg['password'] ?? ''); if ($username === '') { throw new RuntimeException('未配置卷王登录账号'); } $errors = []; // 1) 取 RSA 公钥 $publicKey = ''; try { $r = $this->raw('/system'); if (is_array($r['data']) && !empty($r['data']['publicKey'])) { $publicKey = (string)$r['data']['publicKey']; } } catch (Throwable $e) { $errors[] = '获取公钥失败:' . $e->getMessage(); } // 2) 社区版 /public/login(令牌可能在 Authorization 头、响应体或 sk-token Cookie 中) try { $pwd = $this->encryptPassword($password, $publicKey); $r = $this->raw('/public/login', [], 'POST', ['username' => $username, 'password' => $pwd]); $tok = $r['headers']['authorization'] ?? ''; $via = 'public/login · Authorization 头'; if ($tok === '' && is_array($r['data'])) { $tok = (string)($r['data']['accessToken'] ?? $r['data']['token'] ?? $r['data']['access_token'] ?? ''); $via = 'public/login · 响应体'; } if ($tok === '' && isset($this->cookies[self::TOKEN_COOKIE])) { // v1.13:令牌只放在 Cookie 里 $tok = (string)$this->cookies[self::TOKEN_COOKIE]; $via = 'public/login · Cookie ' . self::TOKEN_COOKIE; } if ($tok !== '' && $r['status'] < 400) { $this->loginVia = $via; return $this->saveToken($tok); } $errors[] = 'public/login:HTTP ' . $r['status'] . ' ' . ($r['msg'] !== '' ? $r['msg'] : mb_substr($r['raw'], 0, 160)); } catch (Throwable $e) { $errors[] = 'public/login:' . $e->getMessage(); } // 3) 旧版 /system/auth/login try { $r = $this->raw('/system/auth/login', [], 'POST', ['username' => $username, 'password' => $password]); $tok = ''; if (is_array($r['data'])) { $tok = (string)($r['data']['accessToken'] ?? $r['data']['token'] ?? $r['data']['access_token'] ?? ''); } if ($tok === '') { $tok = $r['headers']['authorization'] ?? ''; } if ($tok === '' && isset($this->cookies[self::TOKEN_COOKIE])) { $tok = (string)$this->cookies[self::TOKEN_COOKIE]; } if ($tok !== '') { $this->loginVia = 'system/auth/login'; return $this->saveToken($tok); } $errors[] = 'system/auth/login:HTTP ' . $r['status'] . ' ' . ($r['msg'] !== '' ? $r['msg'] : mb_substr($r['raw'], 0, 160)); } catch (Throwable $e) { $errors[] = 'system/auth/login:' . $e->getMessage(); } // 4) 企业版 OAuth2 try { $url = $this->base() . '/admin-api/oauth2/token'; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], CURLOPT_POSTFIELDS => http_build_query([ 'grant_type' => 'password', 'username' => $username, 'password' => $password, ]), CURLOPT_TIMEOUT => $this->timeout(), CURLOPT_SSL_VERIFYPEER => !empty($this->cfg['verify_ssl']), CURLOPT_SSL_VERIFYHOST => !empty($this->cfg['verify_ssl']) ? 2 : 0, ]); $body = curl_exec($ch); curl_close($ch); $j = json_decode((string)$body, true); if (is_array($j) && !empty($j['data']['access_token'])) { $this->loginVia = 'admin-api/oauth2/token'; return $this->saveToken((string)$j['data']['access_token']); } $errors[] = 'oauth2/token:' . mb_substr((string)$body, 0, 160); } catch (Throwable $e) { $errors[] = 'oauth2/token:' . $e->getMessage(); } throw new RuntimeException("登录卷王失败。请检查【设置】中的网址/账号/密码。\n" . implode("\n", $errors)); } private function saveToken(string $token): string { $token = trim($token); $token = preg_replace('/^Bearer\s+/i', '', $token) ?? $token; $this->token = $token; // JWT 过期时间兜底 8 小时 $exp = time() + 8 * 3600; $parts = explode('.', $token); if (count($parts) === 3) { $payload = json_decode((string)@base64_decode(strtr($parts[1], '-_', '+/')), true); if (is_array($payload) && !empty($payload['exp'])) { $exp = (int)$payload['exp']; } } $this->expireAt = $exp; $this->persistSession(); return $token; } /** * 逐步诊断,定位连接/鉴权问题 * @return array{steps:array} */ public function diagnose(): array { $steps = []; try { $r = $this->raw('/system'); $has = is_array($r['data']) && !empty($r['data']['publicKey']); $steps[] = [ 'step' => 'GET ' . $this->apiBase() . '/system', 'ok' => $r['status'] < 400, 'detail' => 'HTTP ' . $r['status'] . ' · RSA公钥=' . ($has ? '已获取' : '无') . ' · ' . mb_substr($r['raw'], 0, 150), ]; } catch (Throwable $e) { $steps[] = ['step' => 'GET /system', 'ok' => false, 'detail' => $e->getMessage()]; return ['steps' => $steps]; } try { $this->login(true); $steps[] = [ 'step' => '登录', 'ok' => true, 'detail' => '方式=' . $this->loginVia . ' · 令牌=' . substr((string)$this->token, 0, 16) . '… · Cookie=[' . implode(', ', array_keys($this->cookies)) . ']', ]; } catch (Throwable $e) { $steps[] = ['step' => '登录', 'ok' => false, 'detail' => $e->getMessage()]; return ['steps' => $steps]; } foreach (['/project/list', '/answer/list'] as $p) { try { $q = $p === '/answer/list' ? ['projectId' => '', 'current' => 1, 'pageSize' => 1] : ['current' => 1, 'pageSize' => 1]; $r = $this->request($p, $q); $cnt = is_array($r['data']) ? count($r['data']['list'] ?? []) : 0; $steps[] = ['step' => 'GET ' . $p, 'ok' => true, 'detail' => 'HTTP ' . $r['status'] . ' · 本页 ' . $cnt . ' 条 · ' . mb_substr($r['raw'], 0, 150)]; } catch (Throwable $e) { $steps[] = ['step' => 'GET ' . $p, 'ok' => false, 'detail' => $e->getMessage()]; } } return ['steps' => $steps]; } /** RSA 公钥加密(无 openssl 时退回明文,兼容老版本) */ private function encryptPassword(string $password, string $publicKey): string { if ($publicKey === '' || !function_exists('openssl_public_encrypt')) { return $password; } $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(preg_replace('/\s+/', '', $publicKey), 64, "\n") . "-----END PUBLIC KEY-----\n"; $key = openssl_pkey_get_public($pem); if ($key === false) { return $password; } $crypted = ''; if (@openssl_public_encrypt($password, $crypted, $key, OPENSSL_PKCS1_PADDING)) { return base64_encode($crypted); } return $password; } // ---------------- 业务接口 ---------------- /** 问卷 / 考试列表 */ public function listProjects(): array { $size = max(20, (int)($this->cfg['page_size'] ?? 200)); $all = []; for ($page = 1; $page <= 50; $page++) { $r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]); $data = $r['data']; $list = is_array($data) ? ($data['list'] ?? $data) : []; if (!is_array($list) || $list === []) { break; } foreach ($list as $item) { if (is_array($item)) { $all[] = $item; } } $total = is_array($data) ? (int)($data['total'] ?? 0) : 0; if ($total > 0 && count($all) >= $total) { break; } if (count($list) < $size) { break; } } return $all; } /** 问卷 / 考试详情(含题目结构) */ public function getProject(string $id): array { $r = $this->request('/project', ['id' => $id]); return is_array($r['data']) ? $r['data'] : []; } /** 答卷列表(自动翻页取全量) */ public function listAnswers(string $projectId): array { $size = max(20, (int)($this->cfg['page_size'] ?? 200)); $all = []; for ($page = 1; $page <= 100; $page++) { $r = $this->request('/answer/list', [ 'projectId' => $projectId, 'current' => $page, 'pageSize' => $size, ]); $data = $r['data']; $list = is_array($data) ? ($data['list'] ?? $data) : []; if (!is_array($list) || $list === []) { break; } foreach ($list as $item) { if (is_array($item)) { $all[] = $item; } } $total = is_array($data) ? (int)($data['total'] ?? 0) : 0; if ($total > 0 && count($all) >= $total) { break; } if (count($list) < $size) { break; } } return $all; } /** 对外:测试连接 */ public function ping(): array { $t0 = microtime(true); $this->getToken(true); $projects = $this->listProjects(); return [ 'ok' => true, 'projects' => count($projects), 'elapsed' => round((microtime(true) - $t0) * 1000), ]; } }