feat: 登录门认证 + 每套试卷独立满分等增强

- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据
- 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算)
- 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤)
- 移除最高/最低分获得者显示
- 平均用时不换行(避免“秒”字换行)
- 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书
- 统计卡片含合格率(≥60% 满分)
- .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
This commit is contained in:
2026-09-08 15:22:04 +08:00
parent daa05560a1
commit 153c732220
18 changed files with 3391 additions and 0 deletions
+527
View File
@@ -0,0 +1,527 @@
<?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),
];
}
}