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