diff --git a/backend/alembic/versions/e7f8a9b0c1d2_coin_event_uniques.py b/backend/alembic/versions/e7f8a9b0c1d2_coin_event_uniques.py new file mode 100644 index 0000000..f0ac6fc --- /dev/null +++ b/backend/alembic/versions/e7f8a9b0c1d2_coin_event_uniques.py @@ -0,0 +1,48 @@ +"""Уникальность дейли и бонуса уровня (ТЗ 3.13): гонки параллельных запросов. + +Revision ID: e7f8a9b0c1d2 +Revises: d5e6a7b8c9d0 +Create Date: 2026-09-21 13:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "e7f8a9b0c1d2" +down_revision: str | Sequence[str] | None = "d5e6a7b8c9d0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# Частичные индексы — и в Postgres, и в SQLite: дубликаты не допускаются, +# старые строки (NULL-ключи) не трогаем — NULL в уникальном индексе не сталкиваются +DAILY_WHERE = sa.text("source = 'daily'") +LEVEL_WHERE = sa.text("source = 'level' AND level IS NOT NULL") + + +def upgrade() -> None: + op.add_column("coin_events", sa.Column("day", sa.Date(), nullable=True)) + op.create_index( + "uq_coin_daily", + "coin_events", + ["source", "day"], + unique=True, + postgresql_where=DAILY_WHERE, + sqlite_where=DAILY_WHERE, + ) + op.create_index( + "uq_coin_level", + "coin_events", + ["level"], + unique=True, + postgresql_where=LEVEL_WHERE, + sqlite_where=LEVEL_WHERE, + ) + + +def downgrade() -> None: + op.drop_index("uq_coin_level", table_name="coin_events") + op.drop_index("uq_coin_daily", table_name="coin_events") + op.drop_column("coin_events", "day") \ No newline at end of file diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index aff797f..39b2aa0 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -37,6 +37,23 @@ return task +def _get_task_for_update(db: Any, task_id: int) -> Task: + """Задача с блокировкой строки (Postgres FOR UPDATE; в SQLite игнорируется). + + Параллельные PATCH одной задачи сериализуются: двойное закрытие не гонит + двух наград (уникальный XpEvent.task_id — вторая линия обороны). + """ + task = cast( + Task | None, + # of=Task: запрос тянет project joined-ом — Postgres запрещает FOR UPDATE + # на nullable-стороне outer join, блокируем только строку задачи + db.scalar(select(Task).where(Task.id == task_id).with_for_update(of=Task)), + ) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + return task + + def _archived_project_ids(db: Any) -> Select[tuple[int]]: """id проектов в архиве — их задачи в рабочих видах не показываются.""" return select(Project.id).where(Project.is_archived.is_(True)) @@ -128,7 +145,7 @@ async def update_task( task_id: int, schema: TaskUpdate, db: DbDep, user: UserDep, response: Response ) -> Task: - task = _get_task_or_404(db, task_id) + task = _get_task_for_update(db, task_id) data = schema.model_dump(exclude_unset=True) if "title" in data and not str(data["title"]).strip(): @@ -227,6 +244,11 @@ ) -> Task: """Перезапустить автодетализацию (например, после появления новых тегов/проектов).""" task = _get_task_or_404(db, task_id) + if task.detail_state != "raw": + # Утверждённая задача уже детализирована: повтор — no-op, предложение + # не сбрасываем, фоновую LLM не будим (worker всё равно работает + # только с raw) + return task task.ai_proposal = None # Коммит до фоновой работы, чтобы worker не прочитал старое состояние # и его запись не перезаписалась teardown-коммитом (см. create_task). diff --git a/backend/app/api/xp.py b/backend/app/api/xp.py index 968e613..00fce24 100644 --- a/backend/app/api/xp.py +++ b/backend/app/api/xp.py @@ -3,6 +3,7 @@ from typing import Any from fastapi import APIRouter +from sqlalchemy.exc import IntegrityError from app.dependencies import DbDep, UserDep from app.realtime import publish @@ -25,9 +26,17 @@ @router.post("/daily") async def post_daily(db: DbDep, user: UserDep) -> dict[str, Any]: - """Ежедневная награда за визит: 50 монет раз в сутки (идемпотентно).""" + """Ежедневная награда за визит: 50 монет раз в сутки (идемпотентно). + + Гонка двух параллельных POST — оба прошли check-then-insert — ловится + уникальным индексом на коммите: молча отдаём granted=False вместо 500. + """ result = claim_daily(db) if result.get("granted"): - db.commit() + try: + db.commit() + except IntegrityError: + db.rollback() + return {"granted": False, "coins": 0} publish(str(user["user_id"]), "xp.changed", {"celebrate": False}) return result diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index ebac690..a29b0c0 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -156,7 +156,16 @@ """ session = get_session_factory()() try: - task = _get_task(session, task_id) + # Блокировка строки: параллельные закрытия агентами сериализуются, + # двойных наград/спавнов нет (тот же механизм, что в PATCH HTTP-API) + task = cast( + Task | None, + # of=Task — как в PATCH HTTP-API: FOR UPDATE не применим к nullable- + # стороне outer join (project joined-ом), блокируем только задачу + session.scalar(select(Task).where(Task.id == task_id).with_for_update(of=Task)), + ) + if task is None: + raise ValueError(f"Task {task_id} not found") if title is not None: if not title.strip(): raise ValueError("title cannot be empty") diff --git a/backend/app/models.py b/backend/app/models.py index 2e6db37..eb670cf 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -287,6 +287,9 @@ amount: Mapped[int] = mapped_column(Integer) # + заработано / − потрачено # Ключ покупки (декорация или расширение карты) для трат item_key: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None) + # День дейли (source='daily'), UTC: уникальность (source, day) против гонки + # двух параллельных POST /api/xp/daily — оба прошли check-then-insert + day: Mapped[date | None] = mapped_column(Date, nullable=True, default=None) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/services/garden.py b/backend/app/services/garden.py index 9ae5b7c..ca73776 100644 --- a/backend/app/services/garden.py +++ b/backend/app/services/garden.py @@ -9,6 +9,7 @@ from typing import Any from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models import CoinEvent, GardenItem, XpEvent @@ -214,7 +215,20 @@ def ensure_garden(db: Session) -> None: - """Ленивое обслуживание сада: бонусы уровней + авторазмещение новых растений.""" + """Ленивое обслуживание сада: бонусы уровней + авторазмещение новых растений. + + Гонка параллельных GET /api/garden: оба читают «ничего не выдано» и пишут + одно и то же. Работаем в savepoint — при нарушении уникальности (индексы + uq_coin_level / (kind, ref_id)) свой недописанный кусок откатываем и + выходим: сад уже обслужил параллельный запрос, его данные закоммичены. + """ + try: + _ensure_garden_locked(db) + except IntegrityError: + db.rollback() + + +def _ensure_garden_locked(db: Session) -> None: # 1) Бонусы за достигнутые уровни (задним числом — для старых уровней) reached = level_from_xp(total_xp(db)) granted = set( diff --git a/backend/app/services/xp.py b/backend/app/services/xp.py index 89b113d..cc140c9 100644 --- a/backend/app/services/xp.py +++ b/backend/app/services/xp.py @@ -5,7 +5,7 @@ """ import random -from datetime import UTC, datetime, time +from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select @@ -126,15 +126,16 @@ """Ежедневная награда: 50 монет раз в сутки (граница дня — UTC). Идемпотентно: если за текущий день начисление уже было — granted=False. + Гонку двух параллельных POST ловит уникальный индекс (source, day) — + вызывающий обрабатывает IntegrityError как granted=False. """ - now = datetime.now(UTC) - day_start = datetime.combine(now.date(), time.min, tzinfo=UTC) + today = datetime.now(UTC).date() claimed = db.scalar( - select(CoinEvent).where(CoinEvent.source == "daily", CoinEvent.created_at >= day_start) + select(CoinEvent).where(CoinEvent.source == "daily", CoinEvent.day == today) ) if claimed is not None: return {"granted": False, "coins": 0} - db.add(CoinEvent(source="daily", amount=DAILY_COINS)) + db.add(CoinEvent(source="daily", amount=DAILY_COINS, day=today)) db.flush() return {"granted": True, "coins": DAILY_COINS} diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py index 2cbc469..7a7b0cd 100644 --- a/backend/tests/test_detailing.py +++ b/backend/tests/test_detailing.py @@ -215,6 +215,17 @@ assert after["ai_proposal"] is not None +def test_redetail_noop_for_approved(client: TestClient) -> None: + """Утверждённая задача уже детализирована: повтор — no-op, предложение + не сбрасывается (раньше redetail молча стирал ai_proposal).""" + task = create_task(client) + client.post(f"/api/tasks/{task['id']}/approve") + set_proposal(task["id"], {"tags": [], "project": None, "new_project": False, "priority": 5}) + + resp = client.post(f"/api/tasks/{task['id']}/redetail") + assert resp.json()["ai_proposal"] is not None + + def test_attachments_upload_list_fetch_delete(client: TestClient) -> None: task = create_task(client) png = b"\x89PNG\r\n\x1a\nfake-image-bytes" diff --git a/frontend/src/game/GardenRenderer.ts b/frontend/src/game/GardenRenderer.ts index 946875f..523c5ea 100644 --- a/frontend/src/game/GardenRenderer.ts +++ b/frontend/src/game/GardenRenderer.ts @@ -560,6 +560,15 @@ return } const local = this.root.toLocal(e.global) + // бросок мимо сада (за краем мира) — позицию не меняем: cellOfWorld клампит + // координаты и без этой проверки предмет «прилипал» бы к ближайшему краю + if (local.x < 0 || local.y < 0 || local.x >= this.worldWidth() || local.y >= this.worldHeight()) { + this.setHoverCell(null) + const { cx, cy } = this.cellCenter(item.x ?? 0, item.y ?? 0) + view.root.position.set(cx, cy) + restoreZ(item) + return + } const inv = this.cb.inventoryRect() if (inv && item.kind === 'decoration' && this.clientIn(e, inv)) { this.setHoverCell(null) diff --git a/frontend/src/taskui.ts b/frontend/src/taskui.ts index 9377cf8..ca004a8 100644 --- a/frontend/src/taskui.ts +++ b/frontend/src/taskui.ts @@ -212,7 +212,10 @@ export function deadlineOverdue(task: { deadline_date: string | null }): boolean { if (!task.deadline_date) return false - const today = new Date().toISOString().slice(0, 10) + // «Сегодня» — локальная дата пользователя, не UTC: toISOString с ночи до + // полуночи (UTC впереди) называет вчерашним днём ещё не наступивший дедлайн + const now = new Date() + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` return task.deadline_date < today } diff --git a/frontend/src/views/GardenView.vue b/frontend/src/views/GardenView.vue index c1cda74..5a6f86b 100644 --- a/frontend/src/views/GardenView.vue +++ b/frontend/src/views/GardenView.vue @@ -233,11 +233,17 @@ } async function onMove(id: number, x: number | null, y: number | null): Promise { + // пауза на весь свой move-запрос + refetch: 300 мс после жеста может не + // хватить, и чужое событие посреди load() пересоберёт сцену под рукой + dragHold.value = true try { await api.moveGardenItem(id, x, y) await load() // инвентарь и сцена — из одного состояния бэка } catch { await load() // позиция не принята — вернуть из состояния бэка + } finally { + if (dragHoldTimer) clearTimeout(dragHoldTimer) + dragHoldTimer = setTimeout(() => (dragHold.value = false), 50) } } diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 103c584..13d6a04 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -5,6 +5,7 @@ import { useToast } from 'gnexus-ui-kit/vue' import { celebrateEarned } from '../gamification' import { api, ApiError, type Attachment, type Project, type Tag, type Task } from '../api' +import { useRealtimeRefresh } from '../realtime' import { deadlineOverdue, gradeToPriority, priorityOptions, priorityToGrade, priorityLabel, priorityVariant, relevanceLabel, renderMarkdown, setPageTitle, statusOptions } from '../taskui' import PriorityBadge from '../components/PriorityBadge.vue' import MdEditor from '../components/MdEditor.vue' @@ -210,6 +211,15 @@ onMounted(load) +// Реактивность (ТЗ 3.14): задачи проекта меняют MCP и соседние вкладки, +// сам проект могут переименовать/заархивировать — перезагружаемся без навигации +useRealtimeRefresh( + () => { + if (projectId.value) void load() + }, + ['task.changed', 'task.deleted', 'project.changed', 'project.deleted'], +) + // /projects/:id → /projects/:id переиспользует инстанс компонента — // перезагружаемся по смене id (та же логика, что в TaskView) watch(projectId, () => { diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue index 4c56074..8630e50 100644 --- a/frontend/src/views/TaskView.vue +++ b/frontend/src/views/TaskView.vue @@ -91,7 +91,7 @@ const all = await api.listTasks() if (seq !== loadSeq) return tasks.value = all - tagNames.value = fetched.tags.map((t0) => t0.name) + syncTagNames(fetched.tags.map((t0) => t0.name)) if (!tagsCatalog.value.length) { const catalog = await api.listTags() if (seq !== loadSeq) return @@ -162,7 +162,7 @@ if (!task0) return try { task.value = await api.updateTask(task0.id, patch) - tagNames.value = task.value.tags.map((t0) => t0.name) + syncTagNames(task.value.tags.map((t0) => t0.name)) } catch (e) { toast.error({ title: t('common.error'), text: String(e) }) throw e @@ -491,7 +491,7 @@ function onSaved(fresh: Task) { editing.value = false task.value = fresh - tagNames.value = fresh.tags.map((t0) => t0.name) + syncTagNames(fresh.tags.map((t0) => t0.name)) } // Подзадачи: прямые дети текущей задачи, карточками @@ -509,6 +509,17 @@ // Новое имя создаёт тег в каталоге; каждое изменение — PATCH tag_ids. const tagNames = ref([]) const tagsCatalog = ref([]) +// Последнее синхронизированное с бэком состояние списка: SSE-refetch +// переписывает tagNames только если пользователь не начал вводить новый тег — +// иначе чужое событие посреди набора стирает черновик +const syncedTagNames = ref([]) + +function syncTagNames(names: string[]) { + if (JSON.stringify(tagNames.value) === JSON.stringify(syncedTagNames.value)) { + tagNames.value = names + } + syncedTagNames.value = names +} async function onTagAdd(name: string) { const task0 = task.value