Compare commits
2 Commits
3c03866021
...
9c5ef36fe8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c5ef36fe8 | ||
|
|
5f23b8ef5b |
43
AGENTS.md
43
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -151,9 +151,12 @@ if os.path.exists(WEBUI_PATH):
|
||||
@app.get("/{full_path:path}")
|
||||
async def spa_fallback(request: Request, full_path: str):
|
||||
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
|
||||
file_path = os.path.join(WEBUI_PATH, full_path)
|
||||
if os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
# 规范化路径并防止路径穿越攻击
|
||||
safe_path = os.path.normpath(os.path.join(WEBUI_PATH, full_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"))
|
||||
|
||||
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Date
|
||||
from datetime import datetime, timezone, date
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def utcnow():
|
||||
"""统一获取 UTC 时间的工厂函数"""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
from app.utils.datetime import utcnow
|
||||
|
||||
class UserSettings(Base):
|
||||
"""用户设置模型(单例,始终只有一条记录 id=1)"""
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.database import get_db
|
||||
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
|
||||
from app.schemas.account import (
|
||||
AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest,
|
||||
AccountHistoryResponse, AccountListItemResponse,
|
||||
AccountHistoryResponse, AccountListItemResponse, PaginatedAccountHistoryResponse,
|
||||
DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse,
|
||||
)
|
||||
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="更新余额失败")
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/history")
|
||||
@router.get("/accounts/{account_id}/history", response_model=PaginatedAccountHistoryResponse)
|
||||
def get_account_history(
|
||||
account_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -252,26 +252,13 @@ def get_account_history(
|
||||
AccountHistory.created_at.desc()
|
||||
).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
result = {
|
||||
logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"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
|
||||
]
|
||||
"records": records,
|
||||
}
|
||||
|
||||
logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -287,7 +274,7 @@ def get_installments(db: Session = Depends(get_db)):
|
||||
try:
|
||||
installments = db.query(DebtInstallment).order_by(
|
||||
DebtInstallment.is_completed.asc(),
|
||||
DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc()
|
||||
DebtInstallment.id.asc()
|
||||
).all()
|
||||
|
||||
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, "分期计划")
|
||||
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)
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@ from app.utils.logger import logger
|
||||
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:
|
||||
"""计算纪念日的下一次日期、距今天数、周年数"""
|
||||
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:
|
||||
# 计算今年和明年的日期
|
||||
this_year = today.year
|
||||
next_date = date(this_year, month, day)
|
||||
next_date = _safe_date(this_year, month, day)
|
||||
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
|
||||
|
||||
@@ -38,14 +48,14 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
|
||||
else:
|
||||
# 非重复:使用原始日期(加上年份)
|
||||
if anniversary.year:
|
||||
target = date(anniversary.year, month, day)
|
||||
target = _safe_date(anniversary.year, month, day)
|
||||
if target < today:
|
||||
return None, None, None
|
||||
days_until = (target - today).days
|
||||
return target, days_until, 0
|
||||
else:
|
||||
# 无年份的日期按今年算
|
||||
target = date(today.year, month, day)
|
||||
target = _safe_date(today.year, month, day)
|
||||
if target < today:
|
||||
return None, None, None
|
||||
days_until = (target - today).days
|
||||
@@ -219,10 +229,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 +244,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:
|
||||
|
||||
@@ -82,6 +82,14 @@ class AccountHistoryResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PaginatedAccountHistoryResponse(BaseModel):
|
||||
"""分页账户变更历史响应模型"""
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
records: List[AccountHistoryResponse] = []
|
||||
|
||||
|
||||
# ============ 分期还款计划 Schema ============
|
||||
|
||||
class DebtInstallmentBase(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user