Files
ny-exam-platform/lib/SurveyKingClient.php
nxxy335 eb5d06d392 修复考试白名单驱动缺/待考名单:改用卷王项目参与者(Project Partners)真实模型
旧代码误读 surveySetting.whiteLists(实为题目选项白名单),导致线上从未触发。现改为读取 /api/project/partner/list 的 RESPONDENT_SYS_USER/IMP_USER 参与者作为应考名单,并保留 whiteLists 作为极老版本兜底。

dashboard.php 移除未实现的提交率卡片;全_score 默认 100 已在 data 层修正。e2e 测试 mock 与断言同步改为真实模型,全量 132 测试通过。
2026-09-13 11:02:34 +08:00

708 lines
27 KiB
PHP
Raw Permalink 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'] : [];
}
/**
* 取「应考名单」来源(待考 / 缺考名单由此驱动)。
*
* 卷王真实模型(社区版 v1.13 与开源版一致):
* 限制谁可以答卷的是项目「参与者(受访者)」—— t_project_partner 中
* type = RESPONDENT_SYS_USER(2) / RESPONDENT_IMP_USER(3) 的记录,
* 经 /api/project/partner/list 返回,每条带 status(0未访问 / 1已访问未答 / 2已答题)。
* 这才是「考试白名单 / 通讯录名单」的真实载体。
*
* 旧代码误把 surveySetting.whiteLists(按「题目选项」白名单,限制某题可选项)当成应考名单,
* 字段形状根本不对,导致线上从未触发。这里以真实模型为准,whiteLists 仅作极老版本的兜底。
*
* @param string $projectId 问卷 ID
* @param array|null $survey 已解析的 survey(仅极老版本白名单兜底用,新模型不依赖它)
* @return array<int,string> 应考成员标识:优先姓名(便于与本地名单按姓名匹配),其次登陆账号 / 学号
*/
public function getWhitelist(string $projectId, ?array $survey = null): array
{
// 1) 真实模型:项目「受访者」参与者 = 应考名单
$partners = [];
try {
$partners = $this->getRespondentPartners($projectId);
} catch (Throwable $e) {
$this->log[] = 'getRespondentPartners 失败(回退白名单兜底):' . $e->getMessage();
}
if ($partners !== []) {
$out = [];
foreach ($partners as $p) {
if (!is_array($p)) {
continue;
}
$type = (int)($p['type'] ?? 0);
if ($type !== 2 && $type !== 3) {
continue; // 仅取受访者(系统用户 / 导入用户),排除 OWNER / COLLABORATOR
}
$name = trim((string)($p['userName'] ?? ''));
$userId = trim((string)($p['userId'] ?? ''));
$user = $p['user'] ?? null;
$login = '';
if (is_array($user)) {
if ($name === '') {
$name = trim((string)($user['name'] ?? ''));
}
$login = trim((string)($user['login'] ?? $user['account'] ?? $user['username'] ?? ''));
if ($userId === '') {
$userId = trim((string)($user['id'] ?? ''));
}
}
// 与本地名单 / 答卷姓名匹配的标识:姓名 > 登陆账号 > userId
$key = $name !== '' ? $name : ($login !== '' ? $login : $userId);
if ($key !== '') {
$out[] = $key;
}
}
// 去重(大小写不敏感),保留首次出现顺序
$seen = [];
$res = [];
foreach ($out as $item) {
$k = strtolower($item);
if (!isset($seen[$k])) {
$seen[$k] = true;
$res[] = $item;
}
}
if ($res !== []) {
return $res;
}
}
// 2) 兜底:极老 / 定制版本可能把应考名单放在 surveySetting.whiteLists(实为题目选项白名单)
if (is_array($survey)) {
$setting = null;
if (isset($survey['surveySetting']) && is_array($survey['surveySetting'])) {
$setting = $survey['surveySetting'];
} elseif (isset($survey['survey']) && is_array($survey['survey']) && isset($survey['survey']['surveySetting'])) {
$setting = $survey['survey']['surveySetting'];
} else {
// 顶层直接是白名单结构
foreach (['whiteLists', 'whiteList', 'whitelist'] as $wk) {
if (isset($survey[$wk]) && (is_array($survey[$wk]) || is_string($survey[$wk]))) {
$setting = $survey;
break;
}
}
}
if (is_array($setting)) {
return self::parseWhitelist($setting);
}
}
return [];
}
/**
* 取项目的「受访者」参与者列表(应考名单真实来源)。
* GET /api/project/partner/list?projectId=...&types=2,3(RESPONDENT_SYS_USER / RESPONDENT_IMP_USER),自动翻页。
* @return array<int,array{id:string,type:int,userName:string,userId:string,status:int,user?:array}>
*/
public function getRespondentPartners(string $projectId): array
{
$size = 200;
$all = [];
for ($page = 1; $page <= 50; $page++) {
$r = $this->request('/project/partner/list', [
'projectId' => $projectId,
'types' => '2,3',
'current' => $page,
'pageSize' => $size,
]);
$data = $r['data'];
$list = is_array($data) ? ($data['list'] ?? $data) : [];
if (!is_array($list) || $list === []) {
break;
}
foreach ($list as $p) {
if (is_array($p)) {
$all[] = $p;
}
}
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
if ($total > 0 && count($all) >= $total) {
break;
}
if (count($list) < $size) {
break;
}
}
return $all;
}
/**
* 从 surveySetting 中解析白名单成员(去重、保留首次出现顺序)。
* @return array<int,string>
*/
public static function parseWhitelist(array $setting): array
{
$raw = $setting['whiteLists'] ?? $setting['whiteList'] ?? $setting['whitelist'] ?? null;
if ($raw === null || $raw === [] || $raw === '') {
return [];
}
$out = [];
$walk = static function ($node) use (&$out, &$walk): void {
if (is_array($node)) {
// 列表或对象都逐值递归(兼容 { 题ID: [值] })
foreach ($node as $v) {
$walk($v);
}
return;
}
if (is_string($node) && trim($node) !== '') {
$out[] = trim($node);
} elseif (is_scalar($node) && $node !== '' && $node !== null) {
$out[] = (string)$node;
}
};
$walk($raw);
// 去重(大小写不敏感),保留首次出现顺序
$seen = [];
$res = [];
foreach ($out as $item) {
$key = strtolower($item);
if (!isset($seen[$key])) {
$seen[$key] = true;
$res[] = $item;
}
}
return $res;
}
/** 答卷列表(自动翻页取全量) */
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),
];
}
}