7.9 KiB
Phase 3: БД + API (custom_sims)
Status: ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором) Parent plan: PLAN.md Domain: backend
Objective
Сохранение custom-симуляций: таблица БД, CRUD API под авторизацией с проверкой владения, серверная валидация спеки. После фазы спека сохраняется/грузится/удаляется через API.
Tasks
- Миграция
backend/src/db/migrations/071_custom_sims.sql: таблицаcustom_sims(id PK AUTOINCREMENT, owner_id FK users ON DELETE CASCADE, title, description, subject, grade, cat, spec_json TEXT NOT NULL, status TEXT 'draft|published' DEFAULT 'draft', version INT DEFAULT 1, created_at, updated_at) + индексы idx_custom_sims_owner / _status. Идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS). - Контроллер
backend/src/controllers/customSimController.js: list (own + чужой published), get (own ИЛИ published), create (через роут teacher/admin), update, remove. Владение проверяется на каждой мутации (owner ИЛИ admin → иначе 403; нет строки → 404). version++ на update. - Серверная валидация спеки
validateSpec(spec): размер JSON (≤200KB),specVersion=1, лимиты (params≤50, objects≤200, walls≤20, springs≤50, expr≤500 симв., глубина≤8, points≤1000), типы объектов из whitelist (point|segment|vector|circle|rect|polyline|path|label|plot|readout), physics (restitution 0..1, dt 1/2000..1/30, body.mass>0), строки-подписи (text/label/unit/meta/ drag.param/param.label) санитизируются как текст (escape &<>, обрезка). Возврат {ok,error?,clean?}. ⛔ Спека НЕ исполняется на сервере (нет eval/Function/SimExpr). - Роуты
backend/src/routes/customSims.js: router-levelauthMiddleware(read auth-only);GET /+GET /:id(видимость в хендлере);POST /,PUT /:id,DELETE /:id— inlinerequireRole('teacher','admin'). Смонтировано в server.js:app.use('/api/custom-sims', ...). - Клиент
js/api.js:customSimsList/Get/Create/Update/Delete→req(...)+ добавлены вwindow.LS. - Тесты
backend/tests/custom-sims.test.js(24/24 pass): CRUD happy-path, ownership (чужой PUT/DELETE → 403), get чужой draft → 403, get чужой published → 200, admin override, 404, validateSpec (12 кейсов отказа 400), санитизация. Монтирует роут на shared test-app (как lab-links).
Files to Modify/Create
backend/src/db/migrations/0NN_custom_sims.sql(new)backend/src/controllers/customSimController.js(new)backend/src/routes/customSims.js(new)backend/src/server.js— монтирование роутера (modify)js/api.js— клиентские методы (modify)backend/tests/custom-sims.test.js(new)
Acceptance Criteria
- POST сохраняет спеку, GET возвращает свои+published, PUT/DELETE только владельцу/админу (403 иначе).
- Кривая/огромная спека → 400. Тесты зелёные (в пределах baseline).
npm run migrateприменяет таблицу; роут не отдаёт SPA-fallback после рестарта.
Notes
- node:sqlite (DatabaseSync), НЕ better-sqlite3.
- Спека хранится как TEXT(JSON); парс/валидация — на входе.
- Не делать blanket
router.use(requireRole('admin'))— read-роуты auth-only, мутации — inline requireRole.
Review Checklist
- Все задачи выполнены
- Ownership и валидация спеки покрыты тестами (24/24)
- Миграция идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS)
npm run lint:routesчисто (0 unprotected, baseline 0); тесты в пределах baseline
Handoff to Next Phase
Что реализовано (Phase 3)
- Миграция 071
custom_sims(применена к живой БДnpm run migrateбез ошибок). - API
/api/custom-sims(смонтировано вbackend/src/server.jsпосле/api/materials):Метод Путь Доступ Ответ GET /api/custom-simsauth { sims:[{id,owner_id,title,description,subject,grade,cat,status,version,created_at,updated_at}] }(свои любого статуса + чужие published; БЕЗ spec)GET /api/custom-sims/:idauth (own ИЛИ published) { sim:{...meta, spec} }(spec уже распарсен из JSON); чужой draft → 403, нет → 404POST /api/custom-simsteacher/admin 201 { id }; принимает{ title?, description?, subject?, grade?, cat?, status?, spec }; кривая спека → 400PUT /api/custom-sims/:idowner/admin 200 { ok:true }; любое поле опц.;specвалидируется заново и version++; 403/404DELETE /api/custom-sims/:idowner/admin 200 { ok:true }; 403/404 - Клиентские методы (
js/api.js→window.LS):customSimsList(),customSimGet(id),customSimCreate(data),customSimUpdate(id, data),customSimDelete(id).
Форма записи custom_sims
id, owner_id(FK users CASCADE), title, description, subject, grade(INT), cat
(math|phys|chem|bio|game|NULL), spec_json(TEXT — валидированный JSON), status(draft|published,
деф. draft), version(INT, ++ на каждом update со spec), created_at, updated_at.
validateSpec — контракт для Ф4 (билдер)
Билдер должен слать в spec объект формата v1 (specVersion:1, meta, viewport, params[], objects[],
physics?). Сервер вернёт 400 при: spec не объект; specVersion≠1; >200KB JSON; глубина >8;
params>50; objects>200; walls>20; springs>50; строка-выражение >500 симв.; недопустимый type
объекта (вне whitelist point|segment|vector|circle|rect|polyline|path|label|plot|readout);
restitution вне 0..1; dt вне 1/2000..1/30; body.mass≤0. Текстовые поля (text,label,unit,
meta.title/desc, param.label/unit/name, drag.param/paramY, id) обрезаются и
экранируются (& < > → entities) — билдер получит обратно очищенную спеку в GET /:id.
⚠️ Не использовать имя param e (зарезервировано в SimExpr — см. Ф2).
На Ф4 (билдер: какие поля шлёт/получает)
- Создание:
LS.customSimCreate({ title, description?, subject?, grade?, cat?, status?, spec })→{ id }. После —LS.customSimUpdate(id, { spec, status:'published' })для публикации. - Загрузка для редактирования:
LS.customSimGet(id)→{ sim }, гдеsim.spec— готовый объект для монтирования вSimEngine.mount(host, sim.spec). - Список «мои/опубликованные»:
LS.customSimsList()→{ sims }(метаданные без spec; spec тянуть лениво поcustomSimGet). - Билдер должен учитывать, что сервер вернёт спеку санитизированной (escaped-текст) — для KaTeX/canvas это безопасно, но при сравнении «до/после» учитывать экранирование.