From 489d1201597a5a0fc1f5c960eabda47dbf9fde6c Mon Sep 17 00:00:00 2001 From: bboysoul Date: Wed, 9 Sep 2026 12:40:40 +0800 Subject: [PATCH] fix: harden countdown validation and recurrence --- backend/mvp.py | 152 +++++++++++++++++++++------- frontend/src/CountdownPanel.test.ts | 16 +++ frontend/src/CountdownPanel.vue | 13 ++- tests/test_countdowns.py | 147 +++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 40 deletions(-) diff --git a/backend/mvp.py b/backend/mvp.py index 08cc679..3fb53a6 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -10,7 +10,7 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from fastapi.responses import FileResponse -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator from sqlalchemy import case, delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -325,11 +325,19 @@ class CountdownInput(BaseModel): calendar_mode: str = Field("solar", pattern="^(solar|lunar)$") lunar_month: int | None = None lunar_day: int | None = None - ignore_year: bool = False + ignore_year: StrictBool = False kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$") repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$") icon: str = Field("📅", min_length=1, max_length=32) - pinned: bool = False + pinned: StrictBool = False + + @field_validator("title", "icon") + @classmethod + def non_blank_text(cls, value: str): + value = value.strip() + if not value: + raise ValueError("text cannot be blank") + return value @model_validator(mode="after") def calendar_fields_valid(self): @@ -356,10 +364,21 @@ class CountdownUpdate(BaseModel): calendar_mode: str | None = Field(None, pattern="^(solar|lunar)$") lunar_month: int | None = None lunar_day: int | None = None - ignore_year: bool | None = None + ignore_year: StrictBool | None = None kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$") repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$") icon: str | None = Field(None, min_length=1, max_length=32) + expected_updated_at: datetime | None = None + + @field_validator("title", "icon") + @classmethod + def non_blank_text(cls, value: str | None): + if value is None: + return value + value = value.strip() + if not value: + raise ValueError("text cannot be blank") + return value @model_validator(mode="after") def reject_explicit_nulls(self): @@ -375,10 +394,10 @@ class CountdownUpdate(BaseModel): def countdown_dict(row: Countdown, today: date | None = None): today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date() - if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule != "none"): - # 农历按年重复:忽略年份或指定重复时,都按“每年农历”语义计算下一次 + if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule == "yearly"): + search_from = max(today, row.event_date) if not row.ignore_year else today display_date = next_lunar_occurrence( - row.lunar_month, row.lunar_day, True, "yearly", today + row.lunar_month, row.lunar_day, True, "yearly", search_from ) or row.event_date effective_repeat = "yearly" else: @@ -392,7 +411,7 @@ def countdown_dict(row: Countdown, today: date | None = None): lunar_year, _, _ = solar_to_lunar_parts(row.event_date) lunar_text = ( solar_to_lunar_text(display_date) - if row.ignore_year or row.repeat_rule != "none" + if row.ignore_year or row.repeat_rule == "yearly" else lunar_label_with_year(row.event_date) ) return { @@ -452,8 +471,25 @@ async def list_countdowns(archived: bool = False, user: User = Depends(current_u @router.patch("/countdowns/{countdown_id}") async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): - row = await owned_countdown(db, user.id, countdown_id) + row = await db.scalar( + select(Countdown) + .where(Countdown.id == countdown_id, Countdown.user_id == user.id) + .with_for_update() + ) + if not row: + raise HTTPException(404, "倒数日不存在") + if row.archived_at is not None: + raise HTTPException(409, "请先恢复倒数日再编辑") values = payload.model_dump(exclude_unset=True) + expected_updated_at = values.pop("expected_updated_at", None) + if expected_updated_at is not None: + actual_updated_at = row.updated_at + if actual_updated_at.tzinfo is None and expected_updated_at.tzinfo is not None: + actual_updated_at = actual_updated_at.replace(tzinfo=UTC) + if expected_updated_at.tzinfo is None and actual_updated_at.tzinfo is not None: + expected_updated_at = expected_updated_at.replace(tzinfo=UTC) + if actual_updated_at != expected_updated_at: + raise HTTPException(409, "倒数日已被其他操作更新,请刷新后重试") combined = { "event_date": values.get("event_date", row.event_date), "calendar_mode": values.get("calendar_mode", row.calendar_mode), @@ -899,10 +935,74 @@ async def restore_csv( return await restore_json(payload, mode, user, db) +def _parse_backup_datetime(value, field_name: str): + if value in (None, ""): + return None + if not isinstance(value, str): + raise HTTPException(422, f"无效的倒数日备份字段:{field_name}") + try: + return datetime.fromisoformat(value) + except ValueError as exc: + raise HTTPException(422, f"无效的倒数日备份字段:{field_name}") from exc + + +def _validate_countdown_backups(payload: dict) -> list[dict]: + raw_items = payload.get("countdowns", []) + if not isinstance(raw_items, list): + raise HTTPException(422, "倒数日备份必须是数组") + parsed = [] + seen_ids = set() + for raw in raw_items: + if not isinstance(raw, dict): + raise HTTPException(422, "无效的倒数日备份数据") + try: + source_id = UUID(raw["id"]) + if source_id in seen_ids: + raise HTTPException(422, "倒数日备份包含重复 ID") + seen_ids.add(source_id) + raw_event_date = date.fromisoformat(raw["event_date"]) + calendar_mode = raw.get("calendar_mode", "solar") + lunar_month = raw.get("lunar_month") + lunar_day = raw.get("lunar_day") + lunar_year = None + validation_date = raw_event_date + if calendar_mode == "lunar": + lunar_year, actual_month, actual_day = solar_to_lunar_parts(raw_event_date) + if (actual_month, actual_day) != (lunar_month, lunar_day): + raise HTTPException(422, "倒数日备份中的公历与农历日期不一致") + validation_date = date(lunar_year, 1, 1) + item = CountdownInput( + title=raw["title"], + event_date=validation_date, + calendar_mode=calendar_mode, + lunar_month=lunar_month, + lunar_day=lunar_day, + ignore_year=raw.get("ignore_year", False), + kind=raw.get("kind", "countdown"), + repeat_rule=raw.get("repeat_rule", "none"), + icon=raw.get("icon", "📅"), + pinned=raw.get("pinned", False), + ) + except HTTPException: + raise + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(422, "无效的倒数日备份数据") from exc + item.event_date = raw_event_date + parsed.append({ + "source_id": source_id, + "item": item, + "archived_at": _parse_backup_datetime(raw.get("archived_at"), "archived_at"), + "created_at": _parse_backup_datetime(raw.get("created_at"), "created_at") or utcnow(), + "updated_at": _parse_backup_datetime(raw.get("updated_at"), "updated_at") or utcnow(), + }) + return parsed + + @router.post("/restore") async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): if payload.get("version") != 1: raise HTTPException(422, "不支持的备份版本") + parsed_countdowns = _validate_countdown_backups(payload) if mode == "replace": await db.execute(delete(Countdown).where(Countdown.user_id == user.id)) await db.execute(delete(Task).where(Task.user_id == user.id)) @@ -992,41 +1092,22 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all()) occupied_countdown_ids = dict((await db.execute(select(Countdown.id, Countdown.user_id))).all()) has_pinned_countdown = bool(await db.scalar(select(Countdown.id).where(Countdown.user_id == user.id, Countdown.pinned.is_(True)))) - for raw in payload.get("countdowns", []): - try: - source_id = UUID(raw["id"]) - except (KeyError, TypeError, ValueError) as exc: - raise HTTPException(422, "无效的倒数日备份 ID") from exc + for parsed in parsed_countdowns: + source_id = parsed["source_id"] row_id = source_id while row_id in occupied_countdown_ids and occupied_countdown_ids[row_id] != user.id: row_id = uuid5(user.id, str(row_id)) if mode == "merge" and row_id in existing_countdown_ids: continue - try: - item = CountdownInput( - title=raw["title"], - event_date=date.fromisoformat(raw["event_date"]), - calendar_mode=raw.get("calendar_mode", "solar"), - lunar_month=raw.get("lunar_month"), - lunar_day=raw.get("lunar_day"), - ignore_year=bool(raw.get("ignore_year", False)), - kind=raw.get("kind", "countdown"), - repeat_rule=raw.get("repeat_rule", "none"), - icon=raw.get("icon", "📅"), - pinned=bool(raw.get("pinned", False)) and not has_pinned_countdown, - ) - # Backups store the canonical solar anchor; validation above converts - # lunar input again, so preserve the exact exported anchor on restore. - item.event_date = date.fromisoformat(raw["event_date"]) - except (KeyError, TypeError, ValueError) as exc: - raise HTTPException(422, "无效的倒数日备份数据") from exc + item = parsed["item"] + item.pinned = item.pinned and not has_pinned_countdown row = Countdown( id=row_id, user_id=user.id, **item.model_dump(), - archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None, - created_at=datetime.fromisoformat(raw["created_at"]) if raw.get("created_at") else utcnow(), - updated_at=datetime.fromisoformat(raw["updated_at"]) if raw.get("updated_at") else utcnow(), + archived_at=parsed["archived_at"], + created_at=parsed["created_at"], + updated_at=parsed["updated_at"], ) if row.archived_at is not None: row.pinned = False @@ -1034,6 +1115,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg db.add(row) existing_countdown_ids.add(row_id) occupied_countdown_ids[row_id] = user.id + restored += 1 audit(db, user.id, "restore", "backup", count=restored, mode=mode) await db.commit() return {"restored": restored, "mode": mode} diff --git a/frontend/src/CountdownPanel.test.ts b/frontend/src/CountdownPanel.test.ts index 3912b72..8f761b5 100644 --- a/frontend/src/CountdownPanel.test.ts +++ b/frontend/src/CountdownPanel.test.ts @@ -52,6 +52,22 @@ describe('countdown modal accessibility', () => { expect(source).not.toContain("Promise.all([request('/countdowns'),request('/countdowns?archived=true')])") }) + it('prevents duplicate submits and sends the edit precondition', () => { + expect(source).toContain('if (busy.value) return') + expect(source).toContain('payload.expected_updated_at=editingItem.value?.updated_at') + expect(source).toContain(':disabled="busy"') + }) + + it('keeps weekly and monthly recurrence available for lunar dates', () => { + expect(source).toContain('') + expect(source).toContain('') + }) + + it('does not repeat the lunar label in the converted date line', () => { + expect(source).toContain("return `${formatDate(item.display_date)}${suffix}`") + expect(source).not.toContain("`${formatDate(item.display_date)} · ${item.lunar_text}${suffix}`") + }) + it('keeps the empty state directly actionable', () => { expect(source).toContain('添加第一个重要日子') expect(source).toContain('@click="openFromEmpty"') diff --git a/frontend/src/CountdownPanel.vue b/frontend/src/CountdownPanel.vue index c4b9c12..c39ed7f 100644 --- a/frontend/src/CountdownPanel.vue +++ b/frontend/src/CountdownPanel.vue @@ -8,14 +8,14 @@ type Countdown = { id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday' repeat_rule: 'none'|'weekly'|'monthly'|'yearly'; icon: string; pinned: boolean; archived_at: string|null; days: number calendar_mode: 'solar'|'lunar'; lunar_year: number|null; lunar_month: number|null; lunar_day: number|null - ignore_year: boolean; lunar_text: string|null + ignore_year: boolean; lunar_text: string|null; updated_at: string } type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean } type CountdownGroup = { key: string; title: string; items: Countdown[] } const emit = defineEmits<{ notice: [message: string] }>() const items = ref([]), archived = ref([]) const showArchived = ref(false), open = ref(false), busy = ref(false) -const editingId = ref(null), error = ref('') +const editingId = ref(null), editingItem = ref(null), error = ref('') const detailItem = ref(null), showAdvanced = ref(false) const currentYear = new Date().getFullYear() const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false }) @@ -55,7 +55,7 @@ function primaryDate(item: Countdown) { function secondaryDate(item: Countdown) { if (item.calendar_mode === 'lunar' && item.lunar_text) { const suffix = item.ignore_year ? ' · 每年农历' : '' - return `${formatDate(item.display_date)} · ${item.lunar_text}${suffix}` + return `${formatDate(item.display_date)}${suffix}` } return '' } @@ -122,6 +122,7 @@ async function load() { function edit(item:Countdown) { detailItem.value=null editingId.value=item.id + editingItem.value=item showAdvanced.value=false form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year } open.value=true @@ -132,9 +133,11 @@ function applyKindDefaults() { if (form.value.kind === 'countdown') form.value.repeat_rule='none' } async function save() { + if (busy.value) return if (!form.value.title.trim()) return await safe(async()=>{ const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year } + if (editingId.value) payload.expected_updated_at=editingItem.value?.updated_at if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day } const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns' await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) }) @@ -162,7 +165,7 @@ function trapDetailFocus(event: KeyboardEvent) { else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } } function openFromEmpty(){openCountdownComposer()} -function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()} +function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()} defineExpose({ openCountdownComposer }) onMounted(load) onBeforeUnmount(() => { previousFocus = null }) @@ -219,7 +222,7 @@ onBeforeUnmount(() => { previousFocus = null }) -
+ diff --git a/tests/test_countdowns.py b/tests/test_countdowns.py index 86d0b20..4030130 100644 --- a/tests/test_countdowns.py +++ b/tests/test_countdowns.py @@ -1,3 +1,4 @@ +from copy import deepcopy from datetime import date from backend.lunar_support import ( @@ -265,3 +266,149 @@ def test_countdown_backup_merge_remaps_ids_owned_by_another_user(client): replaced = client.get("/api/v1/countdowns").json() assert len(replaced) == 1 assert replaced[0]["id"] == items[0]["id"] + + +def test_countdown_rejects_blank_text_and_preserves_lunar_recurrence(client): + boot(client) + assert create_countdown(client, title=" ").status_code == 422 + assert create_countdown(client, icon="\n\t").status_code == 422 + lunar = { + "calendar_mode": "lunar", + "lunar_month": 8, + "lunar_day": 4, + "event_date": "2026-01-01", + } + weekly = create_countdown(client, repeat_rule="weekly", **lunar) + monthly = create_countdown(client, repeat_rule="monthly", **lunar) + assert weekly.status_code == 201 + assert monthly.status_code == 201 + assert weekly.json()["repeat_rule"] == "weekly" + assert monthly.json()["repeat_rule"] == "monthly" + + +def test_future_fixed_year_lunar_countdown_does_not_occur_before_anchor(client): + boot(client) + created = create_countdown( + client, + title="未来农历事件", + event_date="2030-01-01", + calendar_mode="lunar", + lunar_month=1, + lunar_day=1, + repeat_rule="yearly", + ) + assert created.status_code == 201 + item = created.json() + assert item["event_date"] == "2030-02-03" + assert item["display_date"] >= item["event_date"] + + +def test_archived_countdown_cannot_be_edited(client): + boot(client) + item = create_countdown(client).json() + assert client.delete(f"/api/v1/countdowns/{item['id']}").status_code == 204 + response = client.patch(f"/api/v1/countdowns/{item['id']}", json={"title": "归档后偷改"}) + assert response.status_code == 409 + + +def test_stale_countdown_edit_is_rejected(client): + boot(client) + item = create_countdown(client).json() + first = client.patch( + f"/api/v1/countdowns/{item['id']}", + json={"title": "第一次修改", "expected_updated_at": item["updated_at"]}, + ) + assert first.status_code == 200 + stale = client.patch( + f"/api/v1/countdowns/{item['id']}", + json={"title": "过期修改", "expected_updated_at": item["updated_at"]}, + ) + assert stale.status_code == 409 + current = client.get("/api/v1/countdowns").json()[0] + assert current["title"] == "第一次修改" + + +def test_restore_rejects_malformed_countdowns_atomically(client): + boot(client) + original = create_countdown(client, title="必须保留").json() + exported = client.get("/api/v1/export").json() + + malformed = deepcopy(exported) + malformed["countdowns"][0]["archived_at"] = "not-a-date" + response = client.post("/api/v1/restore", params={"mode": "replace"}, json=malformed) + assert response.status_code == 422 + assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] + + for invalid in (None, 7): + malformed = deepcopy(exported) + malformed["countdowns"] = invalid + response = client.post("/api/v1/restore", params={"mode": "replace"}, json=malformed) + assert response.status_code == 422 + assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] + + duplicate = deepcopy(exported) + duplicate["countdowns"].append(deepcopy(duplicate["countdowns"][0])) + response = client.post("/api/v1/restore", params={"mode": "replace"}, json=duplicate) + assert response.status_code == 422 + assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] + + two_pinned = deepcopy(exported) + extra = deepcopy(two_pinned["countdowns"][0]) + extra["id"] = "34deeea0-d976-4581-b378-a66f28623de8" + extra["title"] = "第二个置顶" + two_pinned["countdowns"][0]["pinned"] = True + extra["pinned"] = True + two_pinned["countdowns"].append(extra) + response = client.post("/api/v1/restore", params={"mode": "replace"}, json=two_pinned) + assert response.status_code == 200 + assert sum(item["pinned"] for item in client.get("/api/v1/countdowns").json()) == 1 + + +def test_restore_validates_lunar_metadata_booleans_and_count(client): + boot(client) + item = create_countdown( + client, + title="农历备份", + event_date="2025-01-01", + calendar_mode="lunar", + lunar_month=8, + lunar_day=4, + ).json() + exported = client.get("/api/v1/export").json() + + contradictory = deepcopy(exported) + contradictory["countdowns"][0]["event_date"] = "2025-01-01" + assert client.post("/api/v1/restore", params={"mode": "replace"}, json=contradictory).status_code == 422 + assert client.get("/api/v1/countdowns").json()[0]["id"] == item["id"] + + string_booleans = deepcopy(exported) + string_booleans["countdowns"][0]["ignore_year"] = "false" + string_booleans["countdowns"][0]["pinned"] = "false" + assert client.post("/api/v1/restore", params={"mode": "replace"}, json=string_booleans).status_code == 422 + + clean = deepcopy(exported) + clean["countdowns"][0]["id"] = "13ad79f4-78bc-4a53-a952-e1da84be1a9a" + restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=clean) + assert restored.status_code == 200 + assert restored.json()["restored"] == 1 + + +def test_restore_accepts_lunar_dates_whose_solar_anchor_is_in_next_year(client): + boot(client) + created = create_countdown( + client, + title="跨公历年农历日", + event_date="2001-01-01", + calendar_mode="lunar", + lunar_month=12, + lunar_day=20, + ) + assert created.status_code == 201 + assert created.json()["event_date"] == "2002-02-01" + exported = client.get("/api/v1/export").json() + + restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported) + assert restored.status_code == 200 + item = client.get("/api/v1/countdowns").json()[0] + assert item["event_date"] == "2002-02-01" + assert item["lunar_year"] == 2001