Files
ny-exam-platform/lib/SurveyKingClient.php
T
nxxy335 153c732220 feat: 登录门认证 + 每套试卷独立满分等增强
- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据
- 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算)
- 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤)
- 移除最高/最低分获得者显示
- 平均用时不换行(避免“秒”字换行)
- 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书
- 统计卡片含合格率(≥60% 满分)
- .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
2026-09-08 15:22:04 +08:00

528 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'] ?? '');
}
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),
];
}
}