fix: harden countdown validation and recurrence
This commit is contained in:
+117
-35
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user