Compare commits

..

2 Commits

6 changed files with 73 additions and 51 deletions

View File

@@ -3,7 +3,7 @@
## Project overview ## Project overview
- Full-stack todo app: **Vue 3 + Element Plus** (WebUI/) + **FastAPI + SQLAlchemy** (api/) - Full-stack todo app: **Vue 3 + Element Plus** (WebUI/) + **FastAPI + SQLAlchemy** (api/)
- **Python 3.10+, Node 18+**; SQLite database - **Python 3.10+, Node 18+**; SQLite database
- Single `master` branch, no CI/CD - Single `master` branch (4 commits total), no CI/CD
## Quick commands ## 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. - **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. - **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. - **`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`. - **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()`. - **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. - **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. - **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`). - **`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". - **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** — 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>`. - **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/`) ### Frontend (`WebUI/`)
- Vue Router uses `createWebHistory()` (HTML5 history mode) — **requires the backend SPA fallback** (`/{full_path:path}``index.html`). - 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`. - Vite dev proxy forwards `/api``http://localhost:23994`.
- `@` alias maps to `src/`. - `@` alias maps to `src/`.
- Global styles in SCSS (`src/styles/`). - 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).
- 8 Pinia stores; Element Plus icons registered globally in `main.ts`. - Element Plus icons registered globally in `main.ts` — use `<Edit />`, `<Delete />` etc. in templates without imports.
- Element Plus uses Chinese locale (`zh-cn`). - Element Plus uses Chinese locale (`zh-cn`).
- **Vite 7.x + TypeScript 5.9** with `erasableSyntaxOnly: true` and project references.
### Route registration order matters - **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.
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. - **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 ### Docker
- `Dockerfile` copies pre-built `api/webui/` — you must build frontend before `docker build`. - `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) - No database migrations framework (Alembic)
## Additional notes ## 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). - `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

@@ -151,9 +151,12 @@ if os.path.exists(WEBUI_PATH):
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(request: Request, full_path: str): async def spa_fallback(request: Request, full_path: str):
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html""" """SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
file_path = os.path.join(WEBUI_PATH, full_path) # 规范化路径并防止路径穿越攻击
if os.path.isfile(file_path): safe_path = os.path.normpath(os.path.join(WEBUI_PATH, full_path))
return FileResponse(file_path) if not safe_path.startswith(os.path.normpath(WEBUI_PATH)):
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
if os.path.isfile(safe_path):
return FileResponse(safe_path)
return FileResponse(os.path.join(WEBUI_PATH, "index.html")) return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}") logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")

View File

@@ -1,12 +1,6 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Date from sqlalchemy import Column, Integer, String, Text, DateTime, Date
from datetime import datetime, timezone, date
from app.database import Base from app.database import Base
from app.utils.datetime import utcnow
def utcnow():
"""统一获取 UTC 时间的工厂函数"""
return datetime.now(timezone.utc)
class UserSettings(Base): class UserSettings(Base):
"""用户设置模型(单例,始终只有一条记录 id=1""" """用户设置模型(单例,始终只有一条记录 id=1"""

View File

@@ -8,7 +8,7 @@ from app.database import get_db
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
from app.schemas.account import ( from app.schemas.account import (
AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest, AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest,
AccountHistoryResponse, AccountListItemResponse, AccountHistoryResponse, AccountListItemResponse, PaginatedAccountHistoryResponse,
DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse, DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse,
) )
from app.schemas.common import DeleteResponse from app.schemas.common import DeleteResponse
@@ -232,7 +232,7 @@ def update_balance(account_id: int, data: BalanceUpdateRequest, db: Session = De
raise HTTPException(status_code=500, detail="更新余额失败") raise HTTPException(status_code=500, detail="更新余额失败")
@router.get("/accounts/{account_id}/history") @router.get("/accounts/{account_id}/history", response_model=PaginatedAccountHistoryResponse)
def get_account_history( def get_account_history(
account_id: int, account_id: int,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -252,26 +252,13 @@ def get_account_history(
AccountHistory.created_at.desc() AccountHistory.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all() ).offset((page - 1) * page_size).limit(page_size).all()
result = { logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
return {
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
"records": [ "records": records,
{
"id": r.id,
"account_id": r.account_id,
"change_amount": r.change_amount,
"balance_before": r.balance_before,
"balance_after": r.balance_after,
"note": r.note,
"created_at": r.created_at,
}
for r in records
]
} }
logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
return result
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -287,7 +274,7 @@ def get_installments(db: Session = Depends(get_db)):
try: try:
installments = db.query(DebtInstallment).order_by( installments = db.query(DebtInstallment).order_by(
DebtInstallment.is_completed.asc(), DebtInstallment.is_completed.asc(),
DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc() DebtInstallment.id.asc()
).all() ).all()
today = date.today() today = date.today()
@@ -390,6 +377,11 @@ def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Ses
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划") installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
update_data = data.model_dump(exclude_unset=True) 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(): for field, value in update_data.items():
setattr(installment, field, value) setattr(installment, field, value)

View File

@@ -17,6 +17,16 @@ from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["纪念日"]) router = APIRouter(prefix="/api", tags=["纪念日"])
def _safe_date(year: int, month: int, day: int) -> date:
"""安全构造日期,对闰年 2 月 29 日回退到 2 月 28 日"""
try:
return date(year, month, day)
except ValueError:
if month == 2 and day == 29:
return date(year, 2, 28)
raise
def compute_next_info(anniversary: Anniversary, today: date) -> tuple: def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
"""计算纪念日的下一次日期、距今天数、周年数""" """计算纪念日的下一次日期、距今天数、周年数"""
month, day = anniversary.date.month, anniversary.date.day month, day = anniversary.date.month, anniversary.date.day
@@ -24,9 +34,9 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
if anniversary.is_recurring: if anniversary.is_recurring:
# 计算今年和明年的日期 # 计算今年和明年的日期
this_year = today.year this_year = today.year
next_date = date(this_year, month, day) next_date = _safe_date(this_year, month, day)
if next_date < today: if next_date < today:
next_date = date(this_year + 1, month, day) next_date = _safe_date(this_year + 1, month, day)
days_until = (next_date - today).days days_until = (next_date - today).days
@@ -38,14 +48,14 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
else: else:
# 非重复:使用原始日期(加上年份) # 非重复:使用原始日期(加上年份)
if anniversary.year: if anniversary.year:
target = date(anniversary.year, month, day) target = _safe_date(anniversary.year, month, day)
if target < today: if target < today:
return None, None, None return None, None, None
days_until = (target - today).days days_until = (target - today).days
return target, days_until, 0 return target, days_until, 0
else: else:
# 无年份的日期按今年算 # 无年份的日期按今年算
target = date(today.year, month, day) target = _safe_date(today.year, month, day)
if target < today: if target < today:
return None, None, None return None, None, None
days_until = (target - today).days days_until = (target - today).days
@@ -219,10 +229,10 @@ def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)):
db.refresh(db_anniversary) db.refresh(db_anniversary)
today = date.today() 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}") logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}")
return db_anniversary return result
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f"创建纪念日失败: {str(e)}") logger.error(f"创建纪念日失败: {str(e)}")
@@ -234,7 +244,9 @@ def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
"""获取单个纪念日""" """获取单个纪念日"""
try: try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日") anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
return anniversary today = date.today()
result = enrich_anniversary(anniversary, today)
return result
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@@ -82,6 +82,14 @@ class AccountHistoryResponse(BaseModel):
from_attributes = True from_attributes = True
class PaginatedAccountHistoryResponse(BaseModel):
"""分页账户变更历史响应模型"""
total: int
page: int
page_size: int
records: List[AccountHistoryResponse] = []
# ============ 分期还款计划 Schema ============ # ============ 分期还款计划 Schema ============
class DebtInstallmentBase(BaseModel): class DebtInstallmentBase(BaseModel):