diff --git a/backend/mvp.py b/backend/mvp.py index fba80fb..4aee358 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -702,8 +702,24 @@ async def require_positive_log_day(db, habit, day): raise HTTPException(409, "暂停日不可记录正向进度") +async def lock_habit_order(db: AsyncSession, user_id: UUID) -> None: + """Serialize active-habit order changes for one user. + + PostgreSQL supports a row-level user lock. SQLite ignores ``FOR UPDATE``, so + a no-op write acquires its transaction-wide write lock before order reads. + """ + connection = await db.connection() + if connection.dialect.name == "sqlite": + await db.execute( + update(User).where(User.id == user_id).values(id=User.id) + ) + return + await db.scalar(select(User.id).where(User.id == user_id).with_for_update()) + + @router.post("/habits", status_code=201) async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + await lock_habit_order(db, user.id) max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id)) row = Habit(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **payload.model_dump(exclude={"weekdays", "month_days"}), weekdays=",".join(map(str, payload.weekdays)) if payload.weekdays else None, month_days=",".join(map(str, payload.month_days)) if payload.month_days else None) db.add(row); await db.commit(); await db.refresh(row); return habit_dict(row) @@ -712,11 +728,24 @@ async def create_habit(payload: HabitCreate, user: User = Depends(current_user), @router.get("/habits") async def list_habits(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): condition = Habit.archived_at.is_not(None) if archived else Habit.archived_at.is_(None) - return [habit_dict(h) for h in (await db.scalars(select(Habit).where(Habit.user_id == user.id, condition).order_by(Habit.position, Habit.created_at))).all()] + order = ( + (Habit.archived_at.desc(), Habit.created_at.desc(), Habit.id.asc()) + if archived + else (Habit.position, Habit.created_at) + ) + return [ + habit_dict(h) + for h in ( + await db.scalars( + select(Habit).where(Habit.user_id == user.id, condition).order_by(*order) + ) + ).all() + ] @router.put("/habits/reorder", status_code=204) async def reorder_habits(payload: HabitReorder, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + await lock_habit_order(db, user.id) rows = list((await db.scalars(select(Habit).where( Habit.id.in_(payload.habit_ids), Habit.user_id == user.id, Habit.archived_at.is_(None) ))).all()) @@ -759,6 +788,34 @@ async def archive_habit(habit_id: UUID, user: User = Depends(current_user), db: row = await owned_habit(db, user.id, habit_id); row.archived_at = utcnow(); await db.commit(); return Response(status_code=204) +@router.post("/habits/{habit_id}/restore") +async def restore_habit(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + await lock_habit_order(db, user.id) + row = await owned_habit(db, user.id, habit_id) + if row.archived_at is None: + raise HTTPException(409, "习惯未归档") + max_position = await db.scalar(select(func.max(Habit.position)).where( + Habit.user_id == user.id, Habit.archived_at.is_(None) + )) + position = (max_position if max_position is not None else -1) + 1 + result = await db.execute( + update(Habit) + .where( + Habit.id == habit_id, + Habit.user_id == user.id, + Habit.archived_at.is_not(None), + ) + .values(archived_at=None, position=position) + ) + if result.rowcount != 1: + await db.rollback() + raise HTTPException(409, "习惯未归档") + audit(db, user.id, "restore", "habit", row.id) + await db.commit() + await db.refresh(row) + return habit_dict(row) + + @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) diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index e5380eb..6ff7eb3 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -1,8 +1,8 @@