feat: Update API versioning and parser handling for improved schedule updates and error management

This commit is contained in:
2026-10-19 23:29:35 +03:00
parent 85c5baa3e5
commit 68f9b95090
10 changed files with 32 additions and 18 deletions

View File

@@ -47,13 +47,13 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
**Семестры.** Таблица делится на семестры там, где нумерация недель начинается заново или между неделями больше трёх недель. Расписание связывается со своим семестром по тексту Word («осенний семестр 2026/2027»), расписание сессии — по своим датам, документ без указания семестра — по семестру, в котором сегодняшняя дата. Если семестр не найден (например, весенней таблицы ещё нет), даты не рассчитываются и показываются все строки Word.
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: `/api/catalog` сообщает `parserVersion`, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без `parserVersion` перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
Каталог и папка года перечитываются раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с `Cache-Control: no-cache` и `ETag`. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой `304`. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — `429`; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из `X-Forwarded-For`, если прокси находится в локальной или частной сети. Для `/api/timetable` ETag считается до любой работы — из версии документа, парсера и сервера, таблицы недель, параметров запроса и даты, — поэтому неизменённое расписание отвечает `304` примерно за миллисекунду, не открывая документ.
Ошибки приходят как `{ "error": "текст для показа", "code": "…" }`. Клиенты опираются на `code`, а не на текст: `bad_request` (400), `unknown_endpoint`, `document_not_found`, `group_not_found` (404), `file_too_large` (413), `unreadable_file` (422), `too_many_requests` и `busy` (429), `source_unavailable` и `source_invalid` (502, каталог университета не ответил или не распознан), `source_timeout` (504), `internal` (500). Полный список — в `server/errors.js`. Android и iOS при `source_unavailable`, `source_timeout` и `busy` оставляют на экране сохранённую копию и показывают «Повторить».
Ошибки приходят как `{ "error": "текст для показа", "code": "…" }`. Клиенты опираются на `code`, а не на текст: `bad_request` (400), `unknown_endpoint`, `document_not_found`, `group_not_found` (404), `file_too_large` (413), `unreadable_file` (422), `too_many_requests` и `busy` (429), `source_unavailable` и `source_invalid` (502, каталог университета не ответил или не распознан), `source_timeout` и `timeout` (504, университет или сам сервер не ответили вовремя), `internal` (500). Полный список — в `server/errors.js`. Android и iOS при `source_unavailable`, `source_timeout`, `timeout`, `busy` и `too_many_requests` оставляют на экране сохранённую копию и показывают «Повторить».
К каталогу университета сервер обращается встроенным в Node `fetch`: соединения переиспользуются, сертификаты проверяются, перенаправления не принимаются, ответ ограничен 10 МБ и 25 секундами. Обход каталога (около сотни папок) занимает несколько секунд.

View File

@@ -22,8 +22,11 @@ import java.util.concurrent.TimeUnit
*/
class ApiException(message: String, val offline: Boolean = false, val code: String? = null) : Exception(message)
/** Server codes after which trying again later can help. */
private val SOURCE_DOWN = setOf("source_unavailable", "source_timeout", "busy")
/**
* Server codes after which trying again later can help: the university is down or slow, the
* server is busy or did not answer in time, or this address sent too many requests.
*/
private val SOURCE_DOWN = setOf("source_unavailable", "source_timeout", "busy", "timeout", "too_many_requests")
val json = Json {
ignoreUnknownKeys = true

View File

@@ -28,8 +28,9 @@ struct API: Sendable {
private static let log = Logger(subsystem: "org.xtimms.psu.schedule", category: "API")
private static let offline = "Нет связи с сервером расписания."
/// Server codes after which trying again later can help.
private static let sourceDown: Set<String> = ["source_unavailable", "source_timeout", "busy"]
/// Server codes after which trying again later can help: the university is down or slow, the
/// server is busy or did not answer in time, or this address sent too many requests.
private static let sourceDown: Set<String> = ["source_unavailable", "source_timeout", "busy", "timeout", "too_many_requests"]
private static let retries = 3
// The server answers "no-cache" with an ETag: URLSession keeps the answer and asks again

View File

@@ -66,8 +66,9 @@ app.use("/api", (_req, res, next) => {
// 6 — the timetable carries the group's exams and credits from other catalog documents;
// 7 — /api/diagnostics, cells the parser could not read with confidence;
// 8 — /api/ready, and `ready` in /api/health;
// 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers.
const API_VERSION = 9;
// 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers;
// 10 — parserVersion in /api/catalog.
const API_VERSION = 10;
/**
* What the server still waits for before it answers from memory: the catalog, the week table
@@ -221,7 +222,8 @@ app.get("/api/diagnostics", async (req, res, next) => {
});
app.get("/api/catalog", async (req, res, next) => {
try {
res.json(await getCatalog(req.query.refresh === "1"));
// The parser version lets the PWA re-read schedules it saved from an older parser.
res.json({ ...(await getCatalog(req.query.refresh === "1")), parserVersion: PARSER_VERSION });
} catch (e) {
next(e);
}

2
shared/updates.d.ts vendored
View File

@@ -16,7 +16,7 @@ interface SavedLike {
export function namesGroup(fileName: string, group: string): boolean;
export function scheduleUpdate<F extends CatalogFileLike>(
saved: SavedLike | null | undefined,
catalog: { files: F[]; stale?: boolean } | null | undefined,
catalog: { files: F[]; stale?: boolean; parserVersion?: number } | null | undefined,
group: string,
options?: { dismissed?: string[]; schemaVersion?: number },
): { changed: F | null; newer: F | null; missing: boolean } | null;

View File

@@ -15,12 +15,15 @@ export function namesGroup(fileName, group) {
}
/**
* changed: the same document was republished (or parsed by an older version) — reload it quietly.
* changed: the same document was republished, or parsed by an older parser than the server's
* (`catalog.parserVersion`, from API version 10) — reload it quietly.
* newer: another document for this group appeared later, e.g. the next semester — offer it.
* missing: the document left the catalog, e.g. a new academic year began.
*/
export function scheduleUpdate(saved, catalog, group, options = {}) {
const { dismissed = [], schemaVersion = 0 } = options;
// A server without parserVersion never asks for a re-read: comparing with a guess could
// reload the same document on every check.
const { dismissed = [], schemaVersion = catalog?.parserVersion ?? 0 } = options;
const fileId = saved?.source?.fileId;
if (!fileId || !catalog?.files || catalog.stale) return null;
const current = catalog.files.find((f) => f.id === fileId) || null;

View File

@@ -61,8 +61,6 @@ import {
subgroupsOf,
} from "../shared/lessons";
import qrcode from "qrcode-generator";
/** Matches PARSER_VERSION on the server: older saved schedules are re-read once. */
const SCHEMA_VERSION = 7;
const SOURCE =
"https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027";
const DAYS = [
@@ -710,10 +708,8 @@ export default function App() {
lastCheck.current = Date.now();
const c = known === undefined ? await loadCatalog() : known;
if (!c || !schedule) return;
const found = scheduleUpdate(schedule, c, group, {
dismissed: readDismissed(),
schemaVersion: SCHEMA_VERSION,
});
// A schedule read by an older parser than the server's (c.parserVersion) is re-read once.
const found = scheduleUpdate(schedule, c, group, { dismissed: readDismissed() });
if (!found) return setUpdate(null);
// activate() reports what changed for the group.
if (found.changed) await loadFile(found.changed, false, group);

View File

@@ -61,4 +61,6 @@ export interface Catalog {
year: string;
currentYear?: boolean;
expectedYear?: string;
/** The server's parser: saved schedules read by an older one are re-read (API 10+). */
parserVersion?: number;
}

View File

@@ -99,6 +99,7 @@ test("API: ошибки с машиночитаемым кодом", async () =>
test("API: исходный документ, сбой и таймаут университета", async () => {
const catalog = (await json("/api/catalog")).body;
assert.equal(typeof catalog.parserVersion, "number");
const id = (name) => catalog.files.find((f) => f.name === name).id;
const original = await get(`/api/original/${id("23-ЛГ_23-БА.docx")}`);
assert.equal(original.status, 200);

View File

@@ -156,6 +156,12 @@ test("Обновления: тот же файл перечитывается,
scheduleUpdate({ ...saved, schemaVersion: 4 }, { files: [base] }, "23-БА", options).changed,
base,
);
// Without options the server's parser version from the catalog decides; an old server
// without it never triggers a re-read.
const parsed11 = { ...saved, schemaVersion: 11 };
assert.equal(scheduleUpdate(parsed11, { files: [base], parserVersion: 12 }, "23-БА").changed, base);
assert.equal(scheduleUpdate(parsed11, { files: [base], parserVersion: 11 }, "23-БА"), null);
assert.equal(scheduleUpdate(parsed11, { files: [base] }, "23-БА"), null);
const spring = file("b", "23-БА весна.docx", "Mon, 25 Jan 2027 09:00:00 GMT");
const others = [
file("c", "23-БАз.docx", "Mon, 25 Jan 2027 09:00:00 GMT"),