Files
ny-exam-platform/lib/SurveyKingClient.php
T
nxxy335 87750b20c4 fix: 修复拉取大量答卷时内存耗尽(128M)导致后台刷新失败
- SurveyKingClient::listAnswers/listProjects 单页上限夹在 20~200,
  无论配置 page_size 多大,单次 json_decode 体量都被限制,避免超大响应撑爆 128M
- raw() 不再随返回值常驻完整 HTTP 正文(仅留前 2000 字符用于诊断),
  降低 json_decode 之外的额外内存占用;每页处理后及时释放引用
- bootstrap.php 在未被 php_admin_value 硬锁时临时将 memory_limit 放宽到 512M
  作为安全兜底(触顶场景:/api.php?action=data 拉取卷王全量答卷)

根因:error.log 中 PHP Fatal error Allowed memory size of 134217728 bytes exhausted
位于 SurveyKingClient.php:180 json_decode(),由上传名单后答卷量增大、
单页响应整体解码超出内存上限引发。
2026-09-10 15:45:39 +08:00

537 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 卷王问卷系统(SurveyKing)社区版 开放 API 客户端
*
* 社区版 v1.13 认证流程:
* 1) GET {base}{prefix}/system -> 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<int,string> */
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<string,string>,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'] ?? '');
}
// 仅保留前若干字符用于错误诊断;完整正文不再随返回值常驻内存,
// 避免大答卷响应在 json_decode 之外再额外占用一倍内存。
$rawDiag = mb_strcut($rawBody, 0, 2000);
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawDiag, '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<int,array{step:string,ok:bool,detail:string}>}
*/
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 = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
$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
{
// 单页上限夹在 20~200:无论配置 page_size 多大,单次 json_decode 的体量都被限制住,
// 避免一份超大响应直接撑爆 128M。100 页 × 200 = 最多 2 万份,足以覆盖学校级考试。
$size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
$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;
// 及时释放本页引用,降低峰值内存占用
$r = null;
$data = null;
$list = null;
if ($total > 0 && count($all) >= $total) {
break;
}
if (count($all) < $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),
];
}
}