- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据 - 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算) - 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤) - 移除最高/最低分获得者显示 - 平均用时不换行(避免“秒”字换行) - 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书 - 统计卡片含合格率(≥60% 满分) - .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
528 lines
20 KiB
PHP
528 lines
20 KiB
PHP
<?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'] ?? '');
|
||
}
|
||
|
||
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<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 = 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),
|
||
];
|
||
}
|
||
}
|