fix: harden habit input and lifecycle
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m21s

This commit is contained in:
2026-09-09 14:08:19 +08:00
parent 489d120159
commit bc4c281658
10 changed files with 735 additions and 83 deletions
+117 -12
View File
@@ -579,18 +579,74 @@ async def purge_countdown(countdown_id: UUID, user: User = Depends(current_user)
class HabitCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
kind: str = Field("boolean", pattern="^(boolean|numeric)$")
target: float = Field(1, gt=0)
max_value: float | None = Field(None, gt=0)
target: float = Field(1, gt=0, allow_inf_nan=False)
max_value: float | None = Field(None, gt=0, allow_inf_nan=False)
schedule_type: str = Field("daily", pattern="^(daily|weekly|monthly|interval)$")
weekdays: list[int] | None = None
month_days: list[int] | None = None
interval_days: int | None = Field(None, ge=1)
start_date: date = Field(default_factory=date.today)
@field_validator("name")
@classmethod
def clean_name(cls, value):
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
@model_validator(mode="after")
def schedule_valid(self):
if self.schedule_type == "interval" and not self.interval_days: raise ValueError("interval_days required")
if self.kind == "boolean": self.target = 1; self.max_value = 1
def values_valid(self):
if self.schedule_type == "weekly":
if not self.weekdays or len(self.weekdays) != len(set(self.weekdays)) or any(day < 0 or day > 6 for day in self.weekdays):
raise ValueError("weekdays must be non-empty, unique, and between 0 and 6")
elif self.schedule_type == "monthly":
if not self.month_days or len(self.month_days) != len(set(self.month_days)) or any(day < 1 or day > 31 for day in self.month_days):
raise ValueError("month_days must be non-empty, unique, and between 1 and 31")
elif self.schedule_type == "interval" and self.interval_days is None:
raise ValueError("interval_days required")
if self.schedule_type != "weekly":
self.weekdays = None
if self.schedule_type != "monthly":
self.month_days = None
if self.schedule_type != "interval":
self.interval_days = None
if self.kind == "boolean":
self.target = 1
self.max_value = 1
elif self.max_value is not None and self.max_value < self.target:
raise ValueError("max_value must be greater than or equal to target")
return self
class HabitUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
kind: str | None = Field(None, pattern="^(boolean|numeric)$")
target: float | None = Field(None, gt=0, allow_inf_nan=False)
max_value: float | None = Field(None, gt=0, allow_inf_nan=False)
schedule_type: str | None = Field(None, pattern="^(daily|weekly|monthly|interval)$")
weekdays: list[int] | None = None
month_days: list[int] | None = None
interval_days: int | None = Field(None, ge=1)
start_date: date | None = None
@field_validator("name")
@classmethod
def clean_name(cls, value):
if value is None:
return value
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
@model_validator(mode="after")
def reject_nulls(self):
for field in self.model_fields_set:
if getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self
@@ -606,10 +662,10 @@ class HabitReorder(BaseModel):
class HabitLogInput(BaseModel):
day: date
value: float = Field(gt=0)
value: float = Field(gt=0, allow_inf_nan=False)
class HabitLogEdit(BaseModel): value: float = Field(ge=0)
class HabitLogEdit(BaseModel): value: float = Field(ge=0, allow_inf_nan=False)
class PauseInput(BaseModel):
start_date: date
end_date: date
@@ -629,6 +685,23 @@ async def owned_habit(db, user_id, habit_id):
return row
def require_active_habit(habit):
if habit.archived_at is not None:
raise HTTPException(409, "归档习惯不可修改")
async def require_positive_log_day(db, habit, day):
if not scheduled(habit, day):
raise HTTPException(409, "非计划日不可记录正向进度")
paused = await db.scalar(select(HabitPause.id).where(
HabitPause.habit_id == habit.id,
HabitPause.start_date <= day,
HabitPause.end_date >= day,
))
if paused is not None:
raise HTTPException(409, "暂停日不可记录正向进度")
@router.post("/habits", status_code=201)
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id))
@@ -662,11 +735,23 @@ async def reorder_habits(payload: HabitReorder, user: User = Depends(current_use
@router.patch("/habits/{habit_id}")
async def edit_habit(habit_id: UUID, payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
async def edit_habit(habit_id: UUID, payload: HabitUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_habit(db, user.id, habit_id)
for key, value in payload.model_dump(exclude={"weekdays", "month_days"}).items(): setattr(row, key, value)
row.weekdays = ",".join(map(str, payload.weekdays)) if payload.weekdays else None; row.month_days = ",".join(map(str, payload.month_days)) if payload.month_days else None
await db.commit(); return habit_dict(row)
require_active_habit(row)
current = habit_dict(row)
merged = {key: current[key] for key in HabitCreate.model_fields}
merged.update(payload.model_dump(exclude_unset=True))
try:
validated = HabitCreate.model_validate(merged)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
values = validated.model_dump(exclude={"weekdays", "month_days"})
for key, value in values.items():
setattr(row, key, value)
row.weekdays = ",".join(map(str, validated.weekdays)) if validated.weekdays else None
row.month_days = ",".join(map(str, validated.month_days)) if validated.month_days else None
await db.commit()
return habit_dict(row)
@router.delete("/habits/{habit_id}", status_code=204)
@@ -677,6 +762,8 @@ async def archive_habit(habit_id: UUID, user: User = Depends(current_user), db:
@router.delete("/habits/{habit_id}/permanent", status_code=204)
async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_habit(db, user.id, habit_id)
if row.archived_at is None:
raise HTTPException(409, "请先归档再永久删除")
await db.delete(row)
await db.commit()
return Response(status_code=204)
@@ -685,6 +772,8 @@ async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_
@router.post("/habits/{habit_id}/logs")
async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
await require_positive_log_day(db, habit, payload.day)
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == payload.day))
value = min((row.value if row else 0) + payload.value, habit.max_value or float("inf"))
if habit.kind == "boolean": value = 1
@@ -696,13 +785,27 @@ async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Dep
@router.put("/habits/{habit_id}/logs/{day}")
async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
value = min(payload.value, habit.max_value or float("inf")); value = float(bool(value)) if habit.kind == "boolean" else value
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
if value > (row.value if row else 0):
await require_positive_log_day(db, habit, day)
if row: row.value = value; row.updated_at = utcnow()
else: row = HabitLog(habit_id=habit.id, day=day, value=value); db.add(row)
await db.commit(); return {"day": row.day, "value": row.value}
@router.delete("/habits/{habit_id}/logs/{day}", status_code=204)
async def delete_habit_log(habit_id: UUID, day: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
if row is not None:
await db.delete(row)
await db.commit()
return Response(status_code=204)
@router.get("/habits/{habit_id}/logs")
async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None, alias="from"), to_date: date | None = Query(default=None, alias="to"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id)
@@ -716,7 +819,9 @@ async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None
@router.post("/habits/{habit_id}/pauses", status_code=201)
async def pause_habit(habit_id: UUID, payload: PauseInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id); row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()}
habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()}
def scheduled(h, day):