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
+311
View File
@@ -0,0 +1,311 @@
<?php
/**
* 数据组装:卷王答卷 -> 学号 / 姓名 / 成绩 / 提交时间 / 考试时长
*/
declare(strict_types=1);
class DataService
{
/** 容器型题目(分页、题组、说明)不参与取值 */
private const CONTAINER_TYPES = [
'pagination', 'page', 'group', 'survey', 'divider', 'tree',
];
/** 题目标题可能是富文本,去掉标签便于展示与匹配 */
private static function cleanTitle(string $t): string
{
$t = strip_tags($t);
$t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim(preg_replace('/\s+/u', ' ', $t) ?? '');
}
/**
* 展平题目树
* @return array<int,array{id:string,title:string,type:string,path:string}>
*/
public static function flattenQuestions(mixed $node, string $path = ''): array
{
$out = [];
if (!is_array($node)) {
return $out;
}
// children 在根或子节点
$self = $node;
$children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
$id = isset($self['id']) ? (string)$self['id'] : '';
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
$type = isset($self['type']) ? (string)$self['type'] : '';
$isRoot = ($path === '');
if (!$isRoot && $id !== '' && !in_array(strtolower($type), self::CONTAINER_TYPES, true)) {
$out[] = [
'id' => $id,
'title' => $title !== '' ? $title : $id,
'type' => $type,
'path' => $path,
];
}
if ($children !== null) {
foreach ($children as $idx => $child) {
$subPath = $path === '' ? (string)($idx + 1) : $path . '-' . ($idx + 1);
foreach (self::flattenQuestions($child, $subPath) as $q) {
$out[] = $q;
}
}
}
return $out;
}
/** 根据关键词猜测"姓名"题 */
public static function guessNameQuestion(array $questions, string $keywords): array
{
$kws = array_values(array_filter(array_map('trim', preg_split('/[,,]/u', $keywords) ?: [])));
$candidates = [];
foreach ($questions as $q) {
$t = (string)$q['title'];
foreach ($kws as $kw) {
if ($kw !== '' && mb_stripos($t, $kw) !== false) {
$candidates[] = $q;
break;
}
}
}
return $candidates;
}
/**
* 组装结果
*/
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
{
$client = new SurveyKingClient($cfg);
$students = Roster::all();
// 姓名 -> 学号 / 班级(规范化)
$studentInfo = [];
foreach ($students as $s) {
$k = ny_norm_name((string)$s['name']);
if ($k !== '') {
$studentInfo[$k] = [
'sid' => (string)$s['sid'],
'cls' => (string)($s['cls'] ?? ''),
];
}
}
$project = $client->getProject($projectId);
$survey = $project['survey'] ?? null;
if (is_string($survey) && $survey !== '') {
$decoded = json_decode($survey, true);
if (is_array($decoded)) {
$survey = $decoded;
}
}
$questions = self::flattenQuestions($survey);
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
$nameCandidates = self::guessNameQuestion($questions, $keywords);
$autoQid = $nameCandidates[0]['id'] ?? '';
// 指定优先
$pickedQid = $nameQid !== '' ? $nameQid : $autoQid;
$candidateIds = [];
if ($pickedQid !== '') {
$candidateIds[] = $pickedQid;
}
foreach ($nameCandidates as $q) {
if (!in_array($q['id'], $candidateIds, true)) {
$candidateIds[] = $q['id'];
}
}
$answers = $client->listAnswers($projectId);
$includeTemp = !empty($cfg['include_temp_save']);
$dedupe = !empty($cfg['dedupe']);
$rows = [];
$unmatched = [];
$seenSid = [];
foreach ($answers as $a) {
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
if (!$includeTemp && $tempSave === 0) {
continue; // 跳过暂存
}
$answerMap = isset($a['answer']) && is_array($a['answer']) ? $a['answer'] : [];
// 姓名:优先关键词命中的题目 -> 全量扫描能匹配名单的答案 -> 登录用户名
$name = '';
foreach ($candidateIds as $qid) {
if (isset($answerMap[$qid])) {
$v = ny_flatten_value($answerMap[$qid]);
if ($v !== '') {
$name = $v;
break;
}
}
}
if ($name === '') {
foreach ($questions as $q) {
$qid = (string)$q['id'];
if (!array_key_exists($qid, $answerMap)) {
continue;
}
$v = ny_flatten_value($answerMap[$qid]);
if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
$name = $v;
break;
}
}
}
if ($name === '' && $questions === []) {
foreach ($answerMap as $v) {
$s = ny_flatten_value($v);
if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
$name = $s;
break;
}
}
}
if ($name === '') {
$name = ny_str($a['createByName'] ?? '');
}
if ($name === '') {
$name = '(未填写姓名)';
}
// 成绩
$score = null;
if (isset($a['examScore']) && is_numeric($a['examScore'])) {
$score = (float)$a['examScore'];
}
// 提交时间
$ts = ny_to_timestamp($a['createAt'] ?? null);
if ($ts === 0) {
$ts = ny_to_timestamp($a['updateAt'] ?? null);
}
// 考试时长
$ai = $a['metaInfo']['answerInfo'] ?? [];
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
$dur = null;
if ($start > 0 && $end > $start) {
$dur = (int)round(($end - $start) / 1000);
}
$key = ny_norm_name($name);
$info = $studentInfo[$key] ?? null;
$sid = $info['sid'] ?? '';
$cls = $info['cls'] ?? '';
$row = [
'sid' => $sid,
'name' => $name,
'cls' => $cls,
'score' => $score,
'submit_ts' => $ts,
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
'duration' => $dur,
'duration_txt'=> ny_format_duration($dur),
'answer_id' => ny_str($a['id'] ?? ''),
'matched' => $sid !== '',
'temp_save' => $tempSave === 0,
];
if ($sid === '') {
$unmatched[] = $row;
continue;
}
if ($dedupe && isset($seenSid[$sid])) {
$idx = $seenSid[$sid];
// 保留提交时间最新的一份
if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
$rows[$idx] = $row;
$seenSid[$sid] = $idx;
}
continue;
}
$seenSid[$sid] = count($rows);
$rows[] = $row;
}
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
usort($rows, static function (array $x, array $y): int {
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0);
if ($d !== 0) {
return $d;
}
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
// 缺考名单
$submittedSids = [];
foreach ($rows as $r) {
$submittedSids[(string)$r['sid']] = true;
}
$absent = [];
foreach ($students as $s) {
if (!isset($submittedSids[(string)$s['sid']])) {
$absent[] = [
'sid' => (string)$s['sid'],
'name' => (string)$s['name'],
'cls' => (string)($s['cls'] ?? ''),
];
}
}
usort($absent, static function (array $x, array $y): int {
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
// 统计
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'], $rows), static fn($v) => $v !== null));
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'], $rows), static fn($v) => $v !== null));
$stats = [
'roster_total' => count($students),
'submitted' => count($rows),
'absent' => count($absent),
'unmatched' => count($unmatched),
'submit_rate' => count($students) > 0 ? round(count($rows) / count($students) * 100, 1) : 0,
'avg_score' => $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null,
'max_score' => $scores !== [] ? max($scores) : null,
'min_score' => $scores !== [] ? min($scores) : null,
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
'answer_total' => count($answers),
];
return [
'project' => [
'id' => ny_str($project['id'] ?? $projectId),
'name' => ny_str($project['name'] ?? ''),
'mode' => ny_str($project['mode'] ?? ''),
],
'questions' => $questions,
'name_qid' => $pickedQid,
'name_candidates' => array_map(static fn($q) => ['id' => $q['id'], 'title' => $q['title']], $nameCandidates),
'rows' => $rows,
'absent' => $absent,
'unmatched' => $unmatched,
'stats' => $stats,
'roster' => $students,
'roster_count' => count($students),
'updated_at' => date('Y-m-d H:i:s'),
];
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
/**
* 学号名单管理
* 存储:data/roster.json -> {"students":[{"sid":"1","name":"张三","cls":"计算机2401"}], "updated_at":"..."}
* cls 为班级(可空),用于一份考试涉及多个班级时区分。
*/
declare(strict_types=1);
class Roster
{
private static function file(): string
{
return NY_DATA . '/roster.json';
}
/** @return array<int,array{sid:string,name:string,cls:string}> */
public static function all(): array
{
$d = ny_read_json(self::file(), null);
if (!is_array($d) || !isset($d['students']) || !is_array($d['students'])) {
return [];
}
$out = [];
foreach ($d['students'] as $s) {
if (!is_array($s)) {
continue;
}
$out[] = [
'sid' => trim((string)($s['sid'] ?? '')),
'name' => trim((string)($s['name'] ?? '')),
'cls' => trim((string)($s['cls'] ?? '')),
];
}
return $out;
}
public static function meta(): array
{
$d = ny_read_json(self::file(), null);
return [
'count' => count(self::all()),
'updated_at' => is_array($d) ? (string)($d['updated_at'] ?? '') : '',
];
}
public static function save(array $students): bool
{
$clean = [];
foreach ($students as $s) {
$sid = trim((string)($s['sid'] ?? ''));
$name = trim((string)($s['name'] ?? ''));
if ($sid === '' && $name === '') {
continue;
}
$clean[] = [
'sid' => $sid,
'name' => $name,
'cls' => trim((string)($s['cls'] ?? '')),
];
}
return ny_write_json(self::file(), [
'students' => $clean,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
public static function clear(): bool
{
return ny_write_json(self::file(), ['students' => [], 'updated_at' => date('Y-m-d H:i:s')]);
}
public static function count(): int
{
return count(self::all());
}
/**
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
*/
public static function ensureSeeded(): bool
{
if (self::count() > 0) {
return false;
}
$candidates = [
dirname(NY_ROOT) . '/导入模板.xlsx',
NY_ROOT . '/导入模板.xlsx',
NY_DATA . '/导入模板.xlsx',
];
foreach ($candidates as $f) {
if (is_file($f)) {
try {
$res = self::importFile($f, 'xlsx');
return $res['count'] > 0;
} catch (Throwable $e) {
// 忽略
}
}
}
return false;
}
/**
* 解析上传文件 -> 学生数组
* @return array{count:int,students:array}
*/
public static function importFile(string $file, string $ext = ''): array
{
if ($ext === '') {
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
}
$rows = [];
if ($ext === 'xlsx' || $ext === 'xlsm') {
$rows = XlsxReader::read($file);
} elseif ($ext === 'csv' || $ext === 'txt') {
$rows = self::parseCsv($file);
} else {
// 尝试按 xlsx 解析,失败再按 csv
try {
$rows = XlsxReader::read($file);
} catch (Throwable $e) {
$rows = self::parseCsv($file);
}
}
if ($rows === []) {
throw new RuntimeException('文件为空或无法解析');
}
return ['count' => count($rows), 'students' => self::rowsToStudents($rows)];
}
public static function parseCsv(string $file): array
{
$raw = (string)@file_get_contents($file);
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
$rows = [];
if (($fh = fopen('php://memory', 'r+')) === false) {
return [];
}
fwrite($fh, $raw);
rewind($fh);
while (($line = fgetcsv($fh, 0, ',')) !== false) {
if ($line === [null] || (count($line) === 1 && trim((string)$line[0]) === '')) {
continue;
}
$rows[] = array_map(static fn($v) => self::conv((string)$v), $line);
}
fclose($fh);
if ($rows === []) {
// 可能是 tab 分隔
foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) {
if (trim($line) === '') {
continue;
}
$rows[] = array_map([self::class, 'conv'], explode("\t", $line));
}
}
return $rows;
}
private static function conv(string $v): string
{
$v = trim($v);
$enc = mb_detect_encoding($v, ['UTF-8', 'GB18030', 'GBK', 'BIG5'], true);
if ($enc !== false && strtoupper($enc) !== 'UTF-8') {
$v = mb_convert_encoding($v, 'UTF-8', $enc);
}
return $v;
}
/**
* 识别 学号/姓名/班级 列
* @return array<int,array{sid:string,name:string,cls:string}>
*/
public static function rowsToStudents(array $rows): array
{
$sidCol = -1;
$nameCol = -1;
$clsCol = -1;
$headerIdx = -1;
// 找表头
foreach ($rows as $i => $row) {
$joined = implode('|', array_map('strval', $row));
if (preg_match('/学\s*号|编\s*号|序\s*号|no\.?|id/i', $joined)
&& preg_match('/姓\s*名|名\s*字|学生|成员/i', $joined)) {
$headerIdx = $i;
break;
}
}
$start = 0;
if ($headerIdx >= 0) {
$start = $headerIdx + 1;
foreach ($rows[$headerIdx] as $j => $cell) {
$c = (string)$cell;
if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
$sidCol = $j;
}
if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学生姓名/i', $c)) {
$nameCol = $j;
}
if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
$clsCol = $j;
}
}
}
if ($sidCol < 0) {
$sidCol = 0;
}
if ($nameCol < 0) {
$nameCol = $sidCol === 0 ? 1 : 0;
}
$out = [];
for ($i = $start; $i < count($rows); $i++) {
$row = $rows[$i];
$sid = trim((string)($row[$sidCol] ?? ''));
$name = trim((string)($row[$nameCol] ?? ''));
if ($sid === '' && $name === '') {
continue;
}
// 学号去掉 .0 尾巴(Excel 数字读取)
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls];
}
return $out;
}
}
+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),
];
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
/**
* 极简 XLSX 读取器(无第三方依赖)
* 优先使用 ZipArchive,缺失时回退到纯 PHP 的 ZIP 解析(支持 stored / deflate)。
*/
declare(strict_types=1);
class XlsxReader
{
/**
* @return array<int, array<int, string>> 二维数组(行 => 列)
*/
public static function read(string $file): array
{
if (!is_file($file)) {
throw new RuntimeException('文件不存在:' . $file);
}
$entries = self::readZip($file);
$shared = [];
if (isset($entries['xl/sharedStrings.xml'])) {
$shared = self::parseSharedStrings($entries['xl/sharedStrings.xml']);
}
$sheet = self::firstSheetContent($entries);
if ($sheet === null) {
throw new RuntimeException('未找到工作表内容');
}
return self::parseSheet($sheet, $shared);
}
// ---------------- ZIP ----------------
private static function readZip(string $file): array
{
if (class_exists('ZipArchive')) {
$out = [];
$za = new ZipArchive();
if ($za->open($file) === true) {
for ($i = 0; $i < $za->numFiles; $i++) {
$name = $za->getNameIndex($i);
if ($name === false) {
continue;
}
$content = $za->getFromIndex($i);
$out[$name] = $content === false ? '' : $content;
}
$za->close();
return $out;
}
}
return self::parseZipRaw($file);
}
private static function parseZipRaw(string $file): array
{
$data = (string)@file_get_contents($file);
$len = strlen($data);
if ($len < 22) {
throw new RuntimeException('不是有效的 xlsx 文件');
}
$eocd = -1;
for ($i = $len - 22; $i >= 0; $i--) {
if (substr($data, $i, 4) === "\x50\x4b\x05\x06") {
$eocd = $i;
break;
}
}
if ($eocd < 0) {
throw new RuntimeException('不是有效的 xlsx/zip 文件(未找到目录结束标记)');
}
$count = self::u16($data, $eocd + 10);
$offset = self::u32($data, $eocd + 16);
$out = [];
$p = $offset;
for ($i = 0; $i < $count; $i++) {
if (substr($data, $p, 4) !== "\x50\x4b\x01\x02") {
break;
}
$method = self::u16($data, $p + 10);
$compSize = self::u32($data, $p + 20);
$nameLen = self::u16($data, $p + 28);
$extraLen = self::u16($data, $p + 30);
$commentLen = self::u16($data, $p + 32);
$localOff = self::u32($data, $p + 42);
$name = substr($data, $p + 46, $nameLen);
$lNameLen = self::u16($data, $localOff + 26);
$lExtraLen = self::u16($data, $localOff + 28);
$start = $localOff + 30 + $lNameLen + $lExtraLen;
$raw = substr($data, $start, $compSize);
if ($method === 0) {
$content = $raw;
} else {
$content = @gzinflate($raw);
if ($content === false) {
$content = @gzinflate(substr($raw, 2));
}
}
$out[str_replace('\\', '/', $name)] = $content === false ? '' : $content;
$p += 46 + $nameLen + $extraLen + $commentLen;
}
return $out;
}
private static function u16(string $d, int $off): int
{
$s = substr($d, $off, 2);
return $s === '' || strlen($s) < 2 ? 0 : unpack('v', $s)[1];
}
private static function u32(string $d, int $off): int
{
$s = substr($d, $off, 4);
return $s === '' || strlen($s) < 4 ? 0 : unpack('V', $s)[1];
}
// ---------------- XML ----------------
private static function firstSheetContent(array $entries): ?string
{
if (isset($entries['xl/worksheets/sheet1.xml'])) {
return $entries['xl/worksheets/sheet1.xml'];
}
$found = [];
foreach (array_keys($entries) as $k) {
if (preg_match('#^xl/worksheets/sheet\d+\.xml$#', $k)) {
$found[] = $k;
}
}
if ($found === []) {
return null;
}
sort($found, SORT_NATURAL);
return $entries[$found[0]];
}
private static function parseSharedStrings(string $xml): array
{
$out = [];
if (preg_match_all('#<si>(.*?)</si>#s', $xml, $m) > 0) {
foreach ($m[1] as $si) {
if (preg_match_all('#<t[^>]*>(.*?)</t>#s', $si, $tm) > 0) {
$out[] = implode('', array_map(
static fn($x) => html_entity_decode($x, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
$tm[1]
));
} else {
$out[] = '';
}
}
}
return $out;
}
private static function parseSheet(string $xml, array $shared): array
{
$rows = [];
if (preg_match_all('#<row\b[^>]*>(.*?)</row>#s', $xml, $rm) === 0) {
return $rows;
}
foreach ($rm[1] as $rowXml) {
$cells = [];
if (preg_match_all('#<c(?:\s([^>]*?))?(?:/>|>(.*?)</c>)#s', $rowXml, $cm, PREG_SET_ORDER) > 0) {
$autoIdx = 0;
foreach ($cm as $c) {
$attrs = $c[1] ?? '';
$inner = $c[2] ?? '';
$idx = $autoIdx;
if (preg_match('#\br="([A-Z]+)\d*"#', $attrs, $r)) {
$idx = self::colToIndex($r[1]);
}
$autoIdx = $idx + 1;
$type = preg_match('#\bt="([^"]+)"#', $attrs, $tm) ? $tm[1] : '';
if ($type === 'inlineStr') {
$val = '';
if (preg_match_all('#<t[^>]*>(.*?)</t>#s', $inner, $im) > 0) {
$val = implode('', array_map(
static fn($x) => html_entity_decode($x, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
$im[1]
));
}
} else {
$val = preg_match('#<v>(.*?)</v>#s', $inner, $vm)
? html_entity_decode($vm[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')
: '';
if ($type === 's') {
$i = (int)$val;
$val = $shared[$i] ?? '';
} elseif ($type === 'b') {
$val = ((int)$val) === 1 ? 'TRUE' : 'FALSE';
}
}
$cells[$idx] = $val;
}
}
if ($cells === []) {
continue;
}
$max = max(array_keys($cells));
$line = [];
for ($i = 0; $i <= $max; $i++) {
$line[] = $cells[$i] ?? '';
}
$rows[] = $line;
}
return $rows;
}
private static function colToIndex(string $letters): int
{
$n = 0;
$len = strlen($letters);
for ($i = 0; $i < $len; $i++) {
$n = $n * 26 + (ord($letters[$i]) - 64);
}
return $n - 1;
}
}