fix: computed fields missing in anniversary endpoints + missing account_id validation in installment update

This commit is contained in:
祀梦
2026-05-17 12:00:54 +08:00
parent 3c03866021
commit 5f23b8ef5b
3 changed files with 38 additions and 18 deletions

View File

@@ -3,7 +3,7 @@
## Project overview
- Full-stack todo app: **Vue 3 + Element Plus** (WebUI/) + **FastAPI + SQLAlchemy** (api/)
- **Python 3.10+, Node 18+**; SQLite database
- Single `master` branch, no CI/CD
- Single `master` branch (4 commits total), no CI/CD
## Quick commands
@@ -39,24 +39,37 @@ python tests/test_accounts.py
- **Config is hardcoded** in `api/app/config.py` — no `.env`, no `os.getenv()`. Port `23994`, CORS origins `["http://localhost:5173", "http://localhost:23994"]`. Note: `pydantic-settings` is in `requirements.txt` but unused.
- **SQLite path** is computed relative to `api/` via `__file__` — safe regardless of cwd. `connect_args={"check_same_thread": False}` for FastAPI async compatibility.
- **`database.py:init_db()`** auto-creates tables on startup (`create_all`) and auto-adds missing columns via `ALTER TABLE ADD COLUMN` (no Alembic). Columns that are non-nullable with no default are skipped.
- **UserSettings is a singleton**: always id=1, auto-created on first `GET`.
- **Account balance changes** auto-create `AccountHistory` records in `update_balance()`.
- **Habit checkins for the same day** accumulate count (not new rows), enforced by a `(habit_id, checkin_date)` unique constraint.
- **Anniversaries / DebtInstallments** have computed fields (`next_date`, `days_until`, `year_count` / `remaining_periods`) calculated at request time, not stored in DB.
- **`task_tags` M2M table** is defined in `models/tag.py` (not `models/task.py`).
- **Update schemas** use `clearable_fields` + `exclude_unset=True` to distinguish "field not sent" from "field sent as null".
- **JWT authentication** — mandatory for all `/api/*` routes except `/api/auth/*` and `/health`. Default password: `elysia`. Login via `POST /api/auth/login`. Token key in localStorage: `elysia_auth_token`. Frontend axios interceptor auto-attaches `Authorization: Bearer <token>`.
- **UserSettings is a singleton**: always id=1, auto-created on first `GET`. `set_default_password()` auto-initializes password to `"elysia"` on first access if `password_hash` is empty.
- **Account balance changes** auto-create `AccountHistory` records in `update_balance()`. You cannot directly modify balance via `PUT /api/accounts/{id}` — balance is replaced via `POST /api/accounts/{id}/balance`.
- **Habit checkins for the same day** accumulate count (not new rows), enforced by a `(habit_id, checkin_date)` unique constraint. Cancelling reduces count; deleting when count ≤ 0 removes the row.
- **Anniversaries / DebtInstallments** have computed fields (`next_date`, `days_until`, `year_count` / `remaining_periods`) calculated at request time, not stored in DB. The calculation functions live in the router layer (not models), because they depend on `date.today()`.
- **`task_tags` M2M table** is defined in `models/tag.py` (not `models/task.py`). Tags only support create/delete (no update).
- **Update schemas** use `clearable_fields` + `exclude_unset=True` to distinguish "field not sent" from "field sent as null". For non-clearable fields, `None` means "don't change"; for clearable fields, `None` means "clear it". See `schemas/task.py:TaskUpdate`, `schemas/habit.py:HabitUpdate`, `schemas/anniversary.py:AnniversaryUpdate`.
- **JWT authentication** — `utils/auth.py` handles JWT (HS256, key: `"elysia-todo-secret-key-change-in-production"`, 24h expiry). Middleware in `main.py` validates tokens for all `/api/*` routes except `/api/auth/*` and `/health`. Default password: `elysia`. Login via `POST /api/auth/login`. Token key in localStorage: `elysia_auth_token`. **Middleware only validates the token; it does NOT inject user info into `request.state`.** Routes needing user data must call `get_current_user(request)` manually.
- **Different cascade delete strategies:**
- `Category`: refuses deletion if tasks are linked (400)
- `HabitGroup`: sets linked habits' `group_id` to NULL
- `AnniversaryCategory`: sets linked anniversaries' `category_id` to NULL
- `FinancialAccount`: full cascade delete (removes linked history + installments)
### Router registration quirks
- **`/health` MUST be registered before `/{full_path:path}`** in `main.py:114` — otherwise SPA fallback intercepts health checks and returns `index.html`.
- **habits router** (`routers/habits.py`) is an empty-shell router that combines 3 sub-routers via `include_router`: habit-groups (`/api/habit-groups`), habits (`/api/habits`), and checkins (`/api/habits/{habit_id}/checkins`).
- **anniversaries and accounts routers** BOTH use `prefix="/api"` (not `/api/anniversaries` or `/api/accounts`). Their internal paths are `/anniversaries`, `/anniversary-categories`, `/accounts`, `/debt-installments`, etc. They coexist because internal paths don't overlap — but be careful adding new routes; the first `include_router` match wins.
### Frontend (`WebUI/`)
- Vue Router uses `createWebHistory()` (HTML5 history mode) — **requires the backend SPA fallback** (`/{full_path:path}``index.html`).
- Vite dev proxy forwards `/api``http://localhost:23994`.
- `@` alias maps to `src/`.
- Global styles in SCSS (`src/styles/`).
- 8 Pinia stores; Element Plus icons registered globally in `main.ts`.
- 9 Pinia stores: `auth`, `task`, `category`, `tag`, `habit`, `anniversary`, `account`, `userSettings`, `ui`. The `ui` store manages dialog visibility, editing state, sidebar collapse, and global loading (no API calls).
- Element Plus icons registered globally in `main.ts` — use `<Edit />`, `<Delete />` etc. in templates without imports.
- Element Plus uses Chinese locale (`zh-cn`).
### Route registration order matters
The `/health` endpoint must be registered **before** the `/{full_path:path}` SPA fallback catch-all, otherwise `/health` requests return `index.html`. This is enforced in `api/app/main.py:114` — do not reorder these registrations.
- **Vite 7.x + TypeScript 5.9** with `erasableSyntaxOnly: true` and project references.
- **Frontend task filtering is entirely client-side.** `fetchTasks()` loads all tasks once; filtering, sorting, and pinyin search run in computed getters. No server-side filtering for tasks.
- **Pinyin search** via `pinyin-pro` (`utils/pinyin.ts`) — supports Chinese character search by pinyin initials or full pinyin.
- **Token key `elysia_auth_token` is hardcoded in 3 separate files** (`router/index.ts`, `api/request.ts`, `stores/useAuthStore.ts`). If you rename it, update all 3.
- **401 response triggers a hard page redirect** (`window.location.href = '/login'`) in the axios interceptor, which reloads the SPA entirely. This differs from the router guard's in-app redirect (`return { path: '/login' }`).
- `sass` is a runtime `dependency` (not `devDependency`) in `package.json` — intentional.
### Docker
- `Dockerfile` copies pre-built `api/webui/` — you must build frontend before `docker build`.
@@ -76,6 +89,6 @@ The `/health` endpoint must be registered **before** the `/{full_path:path}` SPA
- No database migrations framework (Alembic)
## Additional notes
- Swagger UI at `/docs` when backend is running — the live, auto-generated API reference.
- Swagger UI at `/docs` when backend is running.
- `python-multipart` in `requirements.txt` is required for FastAPI to parse form data (not optional).
- `sass` is a runtime `dependency` (not `devDependency`) in `package.json` — unusual, but intentional.
- `pydantic-settings` is installed but unused — config is hardcoded.

View File

@@ -390,6 +390,11 @@ def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Ses
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
update_data = data.model_dump(exclude_unset=True)
if "account_id" in update_data:
account = get_or_404(db, FinancialAccount, update_data["account_id"], "账户")
if account.account_type != "debt":
raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户")
for field, value in update_data.items():
setattr(installment, field, value)

View File

@@ -219,10 +219,10 @@ def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)):
db.refresh(db_anniversary)
today = date.today()
next_date, days_until, year_count = compute_next_info(db_anniversary, today)
result = enrich_anniversary(db_anniversary, today)
logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}")
return db_anniversary
return result
except Exception as e:
db.rollback()
logger.error(f"创建纪念日失败: {str(e)}")
@@ -234,7 +234,9 @@ def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
"""获取单个纪念日"""
try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
return anniversary
today = date.today()
result = enrich_anniversary(anniversary, today)
return result
except HTTPException:
raise
except Exception as e: