From e3f73048a756b92a1a291f5242712fb3a1ea8abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=80=E6=A2=A6?= <3501646051@qq.com> Date: Sun, 17 May 2026 12:59:52 +0800 Subject: [PATCH] refactor: remove all asset/account functionality (models, schemas, routers, store, views, components, tests, docs) --- AGENTS.md | 16 +- README.md | 12 +- WebUI/src/App.vue | 4 +- WebUI/src/api/accounts.ts | 64 - WebUI/src/api/types.ts | 89 -- WebUI/src/components/AccountDialog.vue | 361 ------ WebUI/src/components/AccountHistoryDialog.vue | 235 ---- WebUI/src/components/AppHeader.vue | 8 - WebUI/src/components/BalanceDialog.vue | 185 --- WebUI/src/components/InstallmentDialog.vue | 272 ---- WebUI/src/router/index.ts | 6 - WebUI/src/stores/useAccountStore.ts | 208 --- WebUI/src/stores/useUIStore.ts | 4 +- WebUI/src/views/AssetPage.vue | 1134 ----------------- api/app/database.py | 2 +- api/app/models/__init__.py | 2 - api/app/models/account.py | 61 - api/app/routers/__init__.py | 3 +- api/app/routers/accounts.py | 490 ------- api/app/schemas/account.py | 141 -- tests/test_accounts.py | 618 --------- 21 files changed, 11 insertions(+), 3904 deletions(-) delete mode 100644 WebUI/src/api/accounts.ts delete mode 100644 WebUI/src/components/AccountDialog.vue delete mode 100644 WebUI/src/components/AccountHistoryDialog.vue delete mode 100644 WebUI/src/components/BalanceDialog.vue delete mode 100644 WebUI/src/components/InstallmentDialog.vue delete mode 100644 WebUI/src/stores/useAccountStore.ts delete mode 100644 WebUI/src/views/AssetPage.vue delete mode 100644 api/app/models/account.py delete mode 100644 api/app/routers/accounts.py delete mode 100644 api/app/schemas/account.py delete mode 100644 tests/test_accounts.py diff --git a/AGENTS.md b/AGENTS.md index e1c5318..d2e4220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,6 @@ cd api; uvicorn app.main:app --host 0.0.0.0 --port 23994 # Type-check frontend (noEmit; uses project references tsconfig) cd WebUI; npm run typecheck - -# Run the one hand-written test (backend must be running on :23994) -python tests/test_accounts.py ``` **Build order matters:** `python main.py` automatically compiles WebUI (`npm install` + `npm run build`), copies `WebUI/dist/` → `api/webui/`, then starts uvicorn. It checks timestamps to skip rebuilds when frontend is unchanged. @@ -40,9 +37,8 @@ python tests/test_accounts.py - **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`. `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()`. +- **Anniversaries** have computed fields (`next_date`, `days_until`, `year_count`) 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. @@ -50,18 +46,17 @@ python tests/test_accounts.py - `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. +- **anniversaries router** uses `prefix="/api"` (not `/api/anniversaries`). Its internal paths are `/anniversaries`, `/anniversary-categories`, etc. ### 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/`. -- 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: `auth`, `task`, `category`, `tag`, `habit`, `anniversary`, `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 ``, `` etc. in templates without imports. - Element Plus uses Chinese locale (`zh-cn`). - **Vite 7.x + TypeScript 5.9** with `erasableSyntaxOnly: true` and project references. @@ -77,10 +72,7 @@ python tests/test_accounts.py - `docker-compose.yml` mounts `api/data/` and `api/logs/` for persistence; `api/webui/` is read-only. ## Testing quirks -- Only one test file: `tests/test_accounts.py` — **hand-written, no framework** (not pytest). It counts pass/fail manually and uses `requests` directly against `localhost:23994`. -- Backend must be running before executing tests. -- **The test permanently mutates the database** — Section 15 deliberately leaves test data in place for UI display. Run it on a disposable database copy or reset manually if you need a clean state. -- **The test sends no auth headers** and will fail with 401 if JWT auth is enforced. You must first `POST /api/auth/login` with `{"password": "elysia"}` to get a token, then include `Authorization: Bearer ` in requests, or temporarily comment out the auth middleware for testing. +- No test framework or test files currently in the repo. - No test coverage for tasks, habits, anniversaries, or tags. ## What's missing (agents should not assume) diff --git a/README.md b/README.md index e22501f..fa94627 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Elysia ToDo - 爱莉希雅待办事项 -一款全栈个人信息管理应用,集待办任务、习惯打卡、纪念日提醒、资产总览于一体。 +一款全栈个人信息管理应用,集待办任务、习惯打卡、纪念日提醒于一体。 ## 功能概览 @@ -20,12 +20,6 @@ - 支持农历/公历日期 - 倒计时提醒,不错过重要日子 -### 资产总览 -- 财务账户管理(现金、银行卡、电子钱包等) -- 收支记录与历史查询 -- 分期还款跟踪 -- 资产汇总统计 - ### 系统功能 - 偏好设置(站点名称、默认视图等) - 可折叠侧边栏 @@ -130,7 +124,6 @@ npm run dev | 标签 | `/api/tags` | 标签 CRUD | | 习惯 | `/api/habits` | 习惯、习惯组、打卡记录 | | 纪念日 | `/api/anniversaries` | 纪念日、纪念日分类 | -| 资产 | `/api/accounts` | 账户、交易记录、分期还款 | | 设置 | `/api/user-settings` | 用户偏好设置 | | 健康检查 | `/health` | 服务状态检查 | @@ -143,9 +136,6 @@ Category ──< Task >── Tag AnniversaryCategory ──< Anniversary -FinancialAccount ──< AccountHistory - ──< DebtInstallment - UserSettings (单例) ``` diff --git a/WebUI/src/App.vue b/WebUI/src/App.vue index a96e3fb..4a430e1 100644 --- a/WebUI/src/App.vue +++ b/WebUI/src/App.vue @@ -24,7 +24,7 @@ const authStore = useAuthStore() // 路由变化时同步 currentView watch(() => route.meta.view, (view) => { if (view) { - uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets') + uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries') } }, { immediate: true }) @@ -89,7 +89,7 @@ onMounted(async () => { -
+
{ - return get('/accounts') - }, - - getAccount(id: number): Promise { - return get(`/accounts/${id}`) - }, - - createAccount(data: AccountFormData): Promise { - return post('/accounts', data) - }, - - updateAccount(id: number, data: Partial): Promise { - return put(`/accounts/${id}`, data) - }, - - deleteAccount(id: number): Promise<{ success: boolean; message?: string }> { - return del<{ success: boolean; message?: string }>(`/accounts/${id}`) - }, - - // ============ 余额操作 ============ - updateBalance(id: number, data: BalanceUpdateData): Promise { - return post(`/accounts/${id}/balance`, data) - }, - - // ============ 变更历史 ============ - getHistory(id: number, params?: GetAccountHistoryParams): Promise { - return get(`/accounts/${id}/history`, { params }) - }, - - // ============ 分期计划 ============ - getInstallments(): Promise { - return get('/debt-installments') - }, - - createInstallment(data: DebtInstallmentFormData): Promise { - return post('/debt-installments', data) - }, - - updateInstallment(id: number, data: Partial): Promise { - return put(`/debt-installments/${id}`, data) - }, - - deleteInstallment(id: number): Promise<{ success: boolean; message?: string }> { - return del<{ success: boolean; message?: string }>(`/debt-installments/${id}`) - }, - - payInstallment(id: number): Promise { - return patch(`/debt-installments/${id}/pay`) - }, -} diff --git a/WebUI/src/api/types.ts b/WebUI/src/api/types.ts index 41bb05d..1fa50dd 100644 --- a/WebUI/src/api/types.ts +++ b/WebUI/src/api/types.ts @@ -186,93 +186,4 @@ export interface AnniversaryFormData { remind_days_before: number } -// ============ 资产账户相关 ============ -export type AccountType = 'savings' | 'debt' - -export interface FinancialAccount { - id: number - name: string - account_type: AccountType - balance: number - icon: string - color: string - sort_order: number - is_active: boolean - description?: string | null - created_at: string - updated_at: string - installments?: InstallmentInfo[] -} - -export interface AccountFormData { - name: string - account_type: AccountType - balance: number - icon: string - color: string - sort_order: number - is_active: boolean - description?: string | null -} - -export interface BalanceUpdateData { - new_balance: number - note?: string | null -} - -export interface InstallmentInfo { - next_payment_date: string | null - days_until_payment: number | null - remaining_periods: number -} - -export interface AccountHistoryRecord { - id: number - account_id: number - change_amount: number - balance_before: number - balance_after: number - note?: string | null - created_at: string -} - -export interface AccountHistoryResponse { - total: number - page: number - page_size: number - records: AccountHistoryRecord[] -} - -// ============ 分期还款计划相关 ============ - -export interface DebtInstallment { - id: number - account_id: number - total_amount: number - total_periods: number - current_period: number - payment_day: number - payment_amount: number - start_date: string - is_completed: boolean - created_at: string - updated_at: string - next_payment_date: string | null - days_until_payment: number | null - remaining_periods: number | null - account_name: string | null - account_icon: string | null - account_color: string | null -} - -export interface DebtInstallmentFormData { - account_id: number - total_amount: number - total_periods: number - current_period: number - payment_day: number - payment_amount: number - start_date: string - is_completed: boolean -} diff --git a/WebUI/src/components/AccountDialog.vue b/WebUI/src/components/AccountDialog.vue deleted file mode 100644 index e881306..0000000 --- a/WebUI/src/components/AccountDialog.vue +++ /dev/null @@ -1,361 +0,0 @@ - - - - - diff --git a/WebUI/src/components/AccountHistoryDialog.vue b/WebUI/src/components/AccountHistoryDialog.vue deleted file mode 100644 index cf7b3ef..0000000 --- a/WebUI/src/components/AccountHistoryDialog.vue +++ /dev/null @@ -1,235 +0,0 @@ - - - - - diff --git a/WebUI/src/components/AppHeader.vue b/WebUI/src/components/AppHeader.vue index 610fd8c..1644cd0 100644 --- a/WebUI/src/components/AppHeader.vue +++ b/WebUI/src/components/AppHeader.vue @@ -95,14 +95,6 @@ const currentRouteName = computed(() => route.name as string) 纪念日 -
diff --git a/WebUI/src/components/BalanceDialog.vue b/WebUI/src/components/BalanceDialog.vue deleted file mode 100644 index 3bfb6dc..0000000 --- a/WebUI/src/components/BalanceDialog.vue +++ /dev/null @@ -1,185 +0,0 @@ - - - - - diff --git a/WebUI/src/components/InstallmentDialog.vue b/WebUI/src/components/InstallmentDialog.vue deleted file mode 100644 index 207023d..0000000 --- a/WebUI/src/components/InstallmentDialog.vue +++ /dev/null @@ -1,272 +0,0 @@ - - - - - diff --git a/WebUI/src/router/index.ts b/WebUI/src/router/index.ts index 7cbbb54..ac9cb60 100644 --- a/WebUI/src/router/index.ts +++ b/WebUI/src/router/index.ts @@ -49,12 +49,6 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/AnniversaryPage.vue'), meta: { title: '纪念日', view: 'anniversaries' } }, - { - path: '/assets', - name: 'assets', - component: () => import('@/views/AssetPage.vue'), - meta: { title: '资产总览', view: 'assets' } - }, { path: '/settings', name: 'settings', diff --git a/WebUI/src/stores/useAccountStore.ts b/WebUI/src/stores/useAccountStore.ts deleted file mode 100644 index daf9427..0000000 --- a/WebUI/src/stores/useAccountStore.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import { accountApi } from '@/api/accounts' -import type { - FinancialAccount, AccountFormData, BalanceUpdateData, - AccountHistoryRecord, DebtInstallment, DebtInstallmentFormData -} from '@/api/types' - -export const useAccountStore = defineStore('account', () => { - const accounts = ref([]) - const installments = ref([]) - const loading = ref(false) - - const savingsAccounts = computed(() => - accounts.value.filter(a => a.account_type === 'savings' && a.is_active) - ) - - const debtAccounts = computed(() => - accounts.value.filter(a => a.account_type === 'debt' && a.is_active) - ) - - const totalSavings = computed(() => - savingsAccounts.value.reduce((sum, a) => sum + a.balance, 0) - ) - - const totalDebt = computed(() => - debtAccounts.value.reduce((sum, a) => sum + a.balance, 0) - ) - - const netAssets = computed(() => totalSavings.value - totalDebt.value) - - const activeInstallments = computed(() => - installments.value.filter(i => !i.is_completed && i.days_until_payment !== null) - ) - - const upcomingPayments = computed(() => - activeInstallments.value - .filter(i => i.days_until_payment! >= 0) - .sort((a, b) => a.days_until_payment! - b.days_until_payment!) - ) - - // ============ 账户操作 ============ - - async function fetchAccounts() { - loading.value = true - try { - accounts.value = await accountApi.getAccounts() - } catch (error) { - console.error('获取账户列表失败:', error) - } finally { - loading.value = false - } - } - - async function createAccount(data: AccountFormData): Promise { - try { - const account = await accountApi.createAccount(data) - accounts.value.push(account) - return account - } catch (error) { - console.error('创建账户失败:', error) - return null - } - } - - async function updateAccount(id: number, data: Partial): Promise { - try { - const updated = await accountApi.updateAccount(id, data) - const index = accounts.value.findIndex(a => a.id === id) - if (index !== -1) { - accounts.value[index] = updated - } - return updated - } catch (error) { - console.error('更新账户失败:', error) - return null - } - } - - async function deleteAccount(id: number): Promise { - try { - await accountApi.deleteAccount(id) - accounts.value = accounts.value.filter(a => a.id !== id) - return true - } catch (error) { - console.error('删除账户失败:', error) - return false - } - } - - async function updateBalance(id: number, data: BalanceUpdateData): Promise { - try { - const updated = await accountApi.updateBalance(id, data) - const index = accounts.value.findIndex(a => a.id === id) - if (index !== -1) { - accounts.value[index] = updated - } - return updated - } catch (error) { - console.error('更新余额失败:', error) - return null - } - } - - async function fetchHistory(id: number, page = 1, pageSize = 20): Promise { - try { - return await accountApi.getHistory(id, { page, page_size: pageSize }) - } catch (error) { - console.error('获取变更历史失败:', error) - return { total: 0, page: 1, page_size: pageSize, records: [] } - } - } - - // ============ 分期计划操作 ============ - - async function fetchInstallments() { - try { - installments.value = await accountApi.getInstallments() - } catch (error) { - console.error('获取分期计划失败:', error) - } - } - - async function createInstallment(data: DebtInstallmentFormData): Promise { - try { - const inst = await accountApi.createInstallment(data) - installments.value.push(inst) - installments.value.sort((a, b) => { - const aActive = !a.is_completed && a.days_until_payment !== null ? 0 : 1 - const bActive = !b.is_completed && b.days_until_payment !== null ? 0 : 1 - if (aActive !== bActive) return aActive - bActive - return (a.days_until_payment ?? 9999) - (b.days_until_payment ?? 9999) - }) - return inst - } catch (error) { - console.error('创建分期计划失败:', error) - return null - } - } - - async function updateInstallment(id: number, data: Partial): Promise { - try { - const updated = await accountApi.updateInstallment(id, data) - const index = installments.value.findIndex(i => i.id === id) - if (index !== -1) { - installments.value[index] = updated - } - return updated - } catch (error) { - console.error('更新分期计划失败:', error) - return null - } - } - - async function deleteInstallment(id: number): Promise { - try { - await accountApi.deleteInstallment(id) - installments.value = installments.value.filter(i => i.id !== id) - return true - } catch (error) { - console.error('删除分期计划失败:', error) - return false - } - } - - async function payInstallment(id: number): Promise { - try { - const updated = await accountApi.payInstallment(id) - const index = installments.value.findIndex(i => i.id === id) - if (index !== -1) { - installments.value[index] = updated - } - return updated - } catch (error) { - console.error('标记还款失败:', error) - return null - } - } - - async function init() { - await Promise.all([fetchAccounts(), fetchInstallments()]) - } - - return { - accounts, - installments, - loading, - savingsAccounts, - debtAccounts, - totalSavings, - totalDebt, - netAssets, - activeInstallments, - upcomingPayments, - fetchAccounts, - createAccount, - updateAccount, - deleteAccount, - updateBalance, - fetchHistory, - fetchInstallments, - createInstallment, - updateInstallment, - deleteInstallment, - payInstallment, - init, - } -}) diff --git a/WebUI/src/stores/useUIStore.ts b/WebUI/src/stores/useUIStore.ts index b6f9f66..1e3ad38 100644 --- a/WebUI/src/stores/useUIStore.ts +++ b/WebUI/src/stores/useUIStore.ts @@ -12,7 +12,7 @@ export const useUIStore = defineStore('ui', () => { const editingCategory = ref(null) const sidebarCollapsed = ref(false) const globalLoading = ref(false) - const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets'>('list') + const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries'>('list') const calendarMode = ref<'week' | 'monthly'>('monthly') function openTaskDialog(task?: Task) { @@ -43,7 +43,7 @@ export const useUIStore = defineStore('ui', () => { globalLoading.value = loading } - function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets') { + function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries') { currentView.value = view } diff --git a/WebUI/src/views/AssetPage.vue b/WebUI/src/views/AssetPage.vue deleted file mode 100644 index dfd394c..0000000 --- a/WebUI/src/views/AssetPage.vue +++ /dev/null @@ -1,1134 +0,0 @@ - - - - - diff --git a/api/app/database.py b/api/app/database.py index 1a3ecd5..c331347 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -59,7 +59,7 @@ def init_db(): """初始化数据库表,自动补充新增的列""" # 导入所有模型,确保 Base.metadata 包含全部表定义 from app.models import ( # noqa: F401 - task, category, tag, user_settings, habit, anniversary, account, + task, category, tag, user_settings, habit, anniversary, ) Base.metadata.create_all(bind=engine) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index 547c229..c086458 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -4,11 +4,9 @@ from app.models.tag import Tag, task_tags from app.models.user_settings import UserSettings from app.models.habit import HabitGroup, Habit, HabitCheckin from app.models.anniversary import AnniversaryCategory, Anniversary -from app.models.account import FinancialAccount, AccountHistory, DebtInstallment __all__ = [ "Task", "Category", "Tag", "task_tags", "UserSettings", "HabitGroup", "Habit", "HabitCheckin", "AnniversaryCategory", "Anniversary", - "FinancialAccount", "AccountHistory", "DebtInstallment", ] diff --git a/api/app/models/account.py b/api/app/models/account.py deleted file mode 100644 index f91290e..0000000 --- a/api/app/models/account.py +++ /dev/null @@ -1,61 +0,0 @@ -from sqlalchemy import Column, Integer, String, Text, Boolean, Float, DateTime, ForeignKey, Date -from sqlalchemy.orm import relationship -from app.database import Base -from app.utils.datetime import utcnow - - -class FinancialAccount(Base): - """财务账户模型""" - __tablename__ = "financial_accounts" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(100), nullable=False) - account_type = Column(String(20), nullable=False, default="savings") # savings / debt - balance = Column(Float, default=0.0) - icon = Column(String(50), default="wallet") - color = Column(String(20), default="#FFB7C5") - sort_order = Column(Integer, default=0) - is_active = Column(Boolean, default=True) - description = Column(Text, nullable=True) - created_at = Column(DateTime, default=utcnow) - updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) - - # 关联关系 - history_records = relationship("AccountHistory", back_populates="account", cascade="all, delete-orphan") - debt_installments = relationship("DebtInstallment", back_populates="account", cascade="all, delete-orphan") - - -class AccountHistory(Base): - """余额变更历史""" - __tablename__ = "account_history" - - id = Column(Integer, primary_key=True, index=True) - account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False) - change_amount = Column(Float, nullable=False) - balance_before = Column(Float, nullable=False) - balance_after = Column(Float, nullable=False) - note = Column(String(200), nullable=True) - created_at = Column(DateTime, default=utcnow) - - # 关联关系 - account = relationship("FinancialAccount", back_populates="history_records") - - -class DebtInstallment(Base): - """分期还款计划""" - __tablename__ = "debt_installments" - - id = Column(Integer, primary_key=True, index=True) - account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False) - total_amount = Column(Float, nullable=False) - total_periods = Column(Integer, nullable=False) - current_period = Column(Integer, nullable=False, default=1) # 1-based, 指向下一期待还 - payment_day = Column(Integer, nullable=False) # 每月还款日 1-31 - payment_amount = Column(Float, nullable=False) - start_date = Column(Date, nullable=False) - is_completed = Column(Boolean, default=False) - created_at = Column(DateTime, default=utcnow) - updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) - - # 关联关系 - account = relationship("FinancialAccount", back_populates="debt_installments") diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py index af58899..3afe881 100644 --- a/api/app/routers/__init__.py +++ b/api/app/routers/__init__.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts, auth +from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth api_router = APIRouter() @@ -10,4 +10,3 @@ api_router.include_router(tags.router) api_router.include_router(user_settings.router) api_router.include_router(habits.router) api_router.include_router(anniversaries.router) -api_router.include_router(accounts.router) diff --git a/api/app/routers/accounts.py b/api/app/routers/accounts.py deleted file mode 100644 index 18f4943..0000000 --- a/api/app/routers/accounts.py +++ /dev/null @@ -1,490 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from typing import Optional, List -from datetime import date -from calendar import monthrange - -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, PaginatedAccountHistoryResponse, - DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse, -) -from app.schemas.common import DeleteResponse -from app.utils.crud import get_or_404 -from app.utils.datetime import utcnow -from app.utils.logger import logger - -router = APIRouter(prefix="/api", tags=["资产"]) - - -def compute_installment_info(installment: DebtInstallment, today: date) -> dict: - """计算分期计划的下次还款日期、距今天数、剩余期数""" - if installment.is_completed: - return { - "next_payment_date": None, - "days_until_payment": None, - "remaining_periods": 0, - } - - remaining = installment.total_periods - installment.current_period + 1 - if remaining <= 0: - return { - "next_payment_date": None, - "days_until_payment": None, - "remaining_periods": 0, - } - - # 根据 start_date 和 payment_day 计算下一还款日期 - payment_day = installment.payment_day - start_year = installment.start_date.year - start_month = installment.start_date.month - - # 计算当前应还的期数对应的月份 - period_index = installment.current_period - 1 - next_month_year = start_year * 12 + (start_month - 1) + period_index - next_year = next_month_year // 12 - next_month = next_month_year % 12 + 1 - - # 处理 payment_day 超出当月天数的情况 - max_day = monthrange(next_year, next_month)[1] - actual_day = min(payment_day, max_day) - next_payment_date = date(next_year, next_month, actual_day) - - # 如果计算出的日期在 start_date 之前(边界情况),使用 start_date - if next_payment_date < installment.start_date: - next_payment_date = installment.start_date - - days_until = (next_payment_date - today).days - - return { - "next_payment_date": next_payment_date, - "days_until_payment": days_until, - "remaining_periods": remaining, - } - - -# ============ 财务账户 API ============ - -@router.get("/accounts", response_model=List[AccountListItemResponse]) -def get_accounts(db: Session = Depends(get_db)): - """获取所有账户列表""" - try: - accounts = db.query(FinancialAccount).order_by( - FinancialAccount.sort_order.asc(), - FinancialAccount.id.asc() - ).all() - - result = [] - for acc in accounts: - data = { - "id": acc.id, - "name": acc.name, - "account_type": acc.account_type, - "balance": acc.balance, - "icon": acc.icon, - "color": acc.color, - "sort_order": acc.sort_order, - "is_active": acc.is_active, - "description": acc.description, - "created_at": acc.created_at, - "updated_at": acc.updated_at, - } - - # 附加分期计划摘要(欠款账户) - if acc.account_type == "debt": - installments = db.query(DebtInstallment).filter( - DebtInstallment.account_id == acc.id, - DebtInstallment.is_completed == False - ).all() - today = date.today() - active_installments = [] - for inst in installments: - info = compute_installment_info(inst, today) - active_installments.append(info) - data["installments"] = active_installments - else: - data["installments"] = [] - - result.append(data) - - logger.info(f"获取账户列表成功,总数: {len(result)}") - return result - except Exception as e: - logger.error(f"获取账户列表失败: {str(e)}") - raise HTTPException(status_code=500, detail="获取账户列表失败") - - -@router.post("/accounts", response_model=AccountResponse, status_code=201) -def create_account(data: AccountCreate, db: Session = Depends(get_db)): - """创建账户""" - try: - account = FinancialAccount( - name=data.name, - account_type=data.account_type, - balance=data.balance, - icon=data.icon, - color=data.color, - sort_order=data.sort_order, - is_active=data.is_active, - description=data.description, - ) - db.add(account) - db.commit() - db.refresh(account) - logger.info(f"创建账户成功: id={account.id}, name={account.name}") - return account - except Exception as e: - db.rollback() - logger.error(f"创建账户失败: {str(e)}") - raise HTTPException(status_code=500, detail="创建账户失败") - - -@router.get("/accounts/{account_id}", response_model=AccountResponse) -def get_account(account_id: int, db: Session = Depends(get_db)): - """获取单个账户""" - try: - account = get_or_404(db, FinancialAccount, account_id, "账户") - return account - except HTTPException: - raise - except Exception as e: - logger.error(f"获取账户失败: {str(e)}") - raise HTTPException(status_code=500, detail="获取账户失败") - - -@router.put("/accounts/{account_id}", response_model=AccountResponse) -def update_account(account_id: int, data: AccountUpdate, db: Session = Depends(get_db)): - """更新账户基本信息""" - try: - account = get_or_404(db, FinancialAccount, account_id, "账户") - update_data = data.model_dump(exclude_unset=True) - - # 不允许通过此接口修改余额(使用专门的余额更新接口) - if "balance" in update_data: - del update_data["balance"] - - for field, value in update_data.items(): - setattr(account, field, value) - - account.updated_at = utcnow() - db.commit() - db.refresh(account) - logger.info(f"更新账户成功: id={account_id}") - return account - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.error(f"更新账户失败: {str(e)}") - raise HTTPException(status_code=500, detail="更新账户失败") - - -@router.delete("/accounts/{account_id}") -def delete_account(account_id: int, db: Session = Depends(get_db)): - """删除账户(级联删除历史记录和分期计划)""" - try: - account = get_or_404(db, FinancialAccount, account_id, "账户") - db.delete(account) - db.commit() - logger.info(f"删除账户成功: id={account_id}") - return DeleteResponse(message="账户删除成功") - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.error(f"删除账户失败: {str(e)}") - raise HTTPException(status_code=500, detail="删除账户失败") - - -@router.post("/accounts/{account_id}/balance", response_model=AccountResponse) -def update_balance(account_id: int, data: BalanceUpdateRequest, db: Session = Depends(get_db)): - """更新账户余额(自动记录变更历史)""" - try: - account = get_or_404(db, FinancialAccount, account_id, "账户") - old_balance = account.balance - new_balance = data.new_balance - change_amount = round(new_balance - old_balance, 2) - - # 创建历史记录 - history = AccountHistory( - account_id=account_id, - change_amount=change_amount, - balance_before=old_balance, - balance_after=new_balance, - note=data.note, - ) - db.add(history) - - # 更新余额 - account.balance = new_balance - account.updated_at = utcnow() - db.commit() - db.refresh(account) - logger.info(f"更新余额成功: account_id={account_id}, {old_balance} -> {new_balance}") - return account - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.error(f"更新余额失败: {str(e)}") - raise HTTPException(status_code=500, detail="更新余额失败") - - -@router.get("/accounts/{account_id}/history", response_model=PaginatedAccountHistoryResponse) -def get_account_history( - account_id: int, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - db: Session = Depends(get_db)): - """获取账户变更历史""" - try: - account = get_or_404(db, FinancialAccount, account_id, "账户") - - total = db.query(AccountHistory).filter( - AccountHistory.account_id == account_id - ).count() - - records = db.query(AccountHistory).filter( - AccountHistory.account_id == account_id - ).order_by( - AccountHistory.created_at.desc() - ).offset((page - 1) * page_size).limit(page_size).all() - - logger.info(f"获取账户历史成功: account_id={account_id}, total={total}") - return { - "total": total, - "page": page, - "page_size": page_size, - "records": records, - } - except HTTPException: - raise - except Exception as e: - logger.error(f"获取账户历史失败: {str(e)}") - raise HTTPException(status_code=500, detail="获取账户历史失败") - - -# ============ 分期还款计划 API ============ - -@router.get("/debt-installments", response_model=List[DebtInstallmentResponse]) -def get_installments(db: Session = Depends(get_db)): - """获取所有分期计划(含下次还款计算)""" - try: - installments = db.query(DebtInstallment).order_by( - DebtInstallment.is_completed.asc(), - DebtInstallment.id.asc() - ).all() - - today = date.today() - result = [] - for inst in installments: - info = compute_installment_info(inst, today) - data = { - "id": inst.id, - "account_id": inst.account_id, - "total_amount": inst.total_amount, - "total_periods": inst.total_periods, - "current_period": inst.current_period, - "payment_day": inst.payment_day, - "payment_amount": inst.payment_amount, - "start_date": inst.start_date, - "is_completed": inst.is_completed, - "created_at": inst.created_at, - "updated_at": inst.updated_at, - **info, - } - - if inst.account: - data["account_name"] = inst.account.name - data["account_icon"] = inst.account.icon - data["account_color"] = inst.account.color - - result.append(data) - - # 排序:未完成且临近的排前面 - result.sort(key=lambda x: ( - 0 if not x["is_completed"] and x["days_until_payment"] is not None else 1, - 0 if not x["is_completed"] else 1, - x["days_until_payment"] if x["days_until_payment"] is not None else 9999, - )) - - logger.info(f"获取分期计划列表成功,总数: {len(result)}") - return result - except Exception as e: - logger.error(f"获取分期计划列表失败: {str(e)}") - raise HTTPException(status_code=500, detail="获取分期计划列表失败") - - -@router.post("/debt-installments", response_model=DebtInstallmentResponse, status_code=201) -def create_installment(data: DebtInstallmentCreate, db: Session = Depends(get_db)): - """创建分期计划""" - try: - # 验证关联账户存在且为欠款类型 - account = get_or_404(db, FinancialAccount, data.account_id, "账户") - if account.account_type != "debt": - raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户") - - installment = DebtInstallment( - account_id=data.account_id, - total_amount=data.total_amount, - total_periods=data.total_periods, - current_period=data.current_period, - payment_day=data.payment_day, - payment_amount=data.payment_amount, - start_date=data.start_date, - is_completed=data.is_completed, - ) - db.add(installment) - db.commit() - db.refresh(installment) - - # 返回含计算字段的响应 - today = date.today() - info = compute_installment_info(installment, today) - - logger.info(f"创建分期计划成功: id={installment.id}") - return DebtInstallmentResponse( - id=installment.id, - account_id=installment.account_id, - total_amount=installment.total_amount, - total_periods=installment.total_periods, - current_period=installment.current_period, - payment_day=installment.payment_day, - payment_amount=installment.payment_amount, - start_date=installment.start_date, - is_completed=installment.is_completed, - created_at=installment.created_at, - updated_at=installment.updated_at, - **info, - account_name=account.name, - account_icon=account.icon, - account_color=account.color, - ) - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.error(f"创建分期计划失败: {str(e)}") - raise HTTPException(status_code=500, detail="创建分期计划失败") - - -@router.put("/debt-installments/{installment_id}", response_model=DebtInstallmentResponse) -def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Session = Depends(get_db)): - """更新分期计划""" - try: - 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) - - installment.updated_at = utcnow() - db.commit() - db.refresh(installment) - - today = date.today() - info = compute_installment_info(installment, today) - - result = DebtInstallmentResponse( - id=installment.id, - account_id=installment.account_id, - total_amount=installment.total_amount, - total_periods=installment.total_periods, - current_period=installment.current_period, - payment_day=installment.payment_day, - payment_amount=installment.payment_amount, - start_date=installment.start_date, - is_completed=installment.is_completed, - created_at=installment.created_at, - updated_at=installment.updated_at, - **info, - ) - if installment.account: - result.account_name = installment.account.name - result.account_icon = installment.account.icon - result.account_color = installment.account.color - - logger.info(f"更新分期计划成功: id={installment_id}") - return result - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.error(f"更新分期计划失败: {str(e)}") - raise HTTPException(status_code=500, detail="更新分期计划失败") - - -@router.delete("/debt-installments/{installment_id}") -def delete_installment(installment_id: int, db: Session = Depends(get_db)): - """删除分期计划""" - try: - installment = get_or_404(db, DebtInstallment, installment_id, "分期计划") - db.delete(installment) - db.commit() - logger.info(f"删除分期计划成功: id={installment_id}") - return DeleteResponse(message="分期计划删除成功") - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.error(f"删除分期计划失败: {str(e)}") - raise HTTPException(status_code=500, detail="删除分期计划失败") - - -@router.patch("/debt-installments/{installment_id}/pay") -def pay_installment(installment_id: int, db: Session = Depends(get_db)): - """标记已还一期""" - try: - installment = get_or_404(db, DebtInstallment, installment_id, "分期计划") - - if installment.is_completed: - raise HTTPException(status_code=400, detail="该分期计划已全部还清") - - installment.current_period += 1 - - # 检查是否已全部还清 - if installment.current_period > installment.total_periods: - installment.is_completed = True - installment.current_period = installment.total_periods - - installment.updated_at = utcnow() - db.commit() - db.refresh(installment) - - today = date.today() - info = compute_installment_info(installment, today) - - result = DebtInstallmentResponse( - id=installment.id, - account_id=installment.account_id, - total_amount=installment.total_amount, - total_periods=installment.total_periods, - current_period=installment.current_period, - payment_day=installment.payment_day, - payment_amount=installment.payment_amount, - start_date=installment.start_date, - is_completed=installment.is_completed, - created_at=installment.created_at, - updated_at=installment.updated_at, - **info, - ) - if installment.account: - result.account_name = installment.account.name - result.account_icon = installment.account.icon - result.account_color = installment.account.color - - logger.info(f"分期还款成功: id={installment_id}, current_period={installment.current_period}") - return result - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.error(f"分期还款失败: {str(e)}") - raise HTTPException(status_code=500, detail="分期还款失败") diff --git a/api/app/schemas/account.py b/api/app/schemas/account.py deleted file mode 100644 index 3fcd291..0000000 --- a/api/app/schemas/account.py +++ /dev/null @@ -1,141 +0,0 @@ -from pydantic import BaseModel, Field -from datetime import date, datetime -from typing import Optional, List - - -# ============ 财务账户 Schema ============ - -class AccountBase(BaseModel): - """账户基础模型""" - name: str = Field(..., min_length=1, max_length=100) - account_type: str = Field(default="savings", pattern="^(savings|debt)$") - balance: float = Field(default=0.0) - icon: str = Field(default="wallet", max_length=50) - color: str = Field(default="#FFB7C5", max_length=20) - sort_order: int = Field(default=0) - is_active: bool = Field(default=True) - description: Optional[str] = None - - -class AccountCreate(AccountBase): - """创建账户请求模型""" - pass - - -class AccountUpdate(BaseModel): - """更新账户请求模型""" - name: Optional[str] = Field(None, max_length=100) - account_type: Optional[str] = Field(None, pattern="^(savings|debt)$") - balance: Optional[float] = None - icon: Optional[str] = Field(None, max_length=50) - color: Optional[str] = Field(None, max_length=20) - sort_order: Optional[int] = None - is_active: Optional[bool] = None - description: Optional[str] = None - - -class AccountResponse(AccountBase): - """账户响应模型""" - id: int - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class AccountListItemResponse(BaseModel): - """账户列表项响应模型(含分期摘要)""" - id: int - name: str - account_type: str - balance: float - icon: str - color: str - sort_order: int - is_active: bool - description: Optional[str] = None - created_at: datetime - updated_at: datetime - installments: List[dict] = [] - - -class BalanceUpdateRequest(BaseModel): - """更新余额请求模型""" - new_balance: float - note: Optional[str] = Field(None, max_length=200) - - -# ============ 账户变更历史 Schema ============ - -class AccountHistoryResponse(BaseModel): - """变更历史响应模型""" - id: int - account_id: int - change_amount: float - balance_before: float - balance_after: float - note: Optional[str] = None - created_at: datetime - - class Config: - from_attributes = True - - -class PaginatedAccountHistoryResponse(BaseModel): - """分页账户变更历史响应模型""" - total: int - page: int - page_size: int - records: List[AccountHistoryResponse] = [] - - -# ============ 分期还款计划 Schema ============ - -class DebtInstallmentBase(BaseModel): - """分期计划基础模型""" - account_id: int - total_amount: float - total_periods: int = Field(..., ge=1) - current_period: int = Field(default=1, ge=1) - payment_day: int = Field(..., ge=1, le=31) - payment_amount: float = Field(..., gt=0) - start_date: date - is_completed: bool = Field(default=False) - - -class DebtInstallmentCreate(DebtInstallmentBase): - """创建分期计划请求模型""" - pass - - -class DebtInstallmentUpdate(BaseModel): - """更新分期计划请求模型""" - account_id: Optional[int] = None - total_amount: Optional[float] = None - total_periods: Optional[int] = Field(None, ge=1) - current_period: Optional[int] = Field(None, ge=1) - payment_day: Optional[int] = Field(None, ge=1, le=31) - payment_amount: Optional[float] = Field(None, gt=0) - start_date: Optional[date] = None - is_completed: Optional[bool] = None - - -class DebtInstallmentResponse(DebtInstallmentBase): - """分期计划响应模型(含计算字段)""" - id: int - created_at: datetime - updated_at: datetime - - # 计算字段 - next_payment_date: Optional[date] = None - days_until_payment: Optional[int] = None - remaining_periods: Optional[int] = None - - # 关联账户信息 - account_name: Optional[str] = None - account_icon: Optional[str] = None - account_color: Optional[str] = None - - class Config: - from_attributes = True diff --git a/tests/test_accounts.py b/tests/test_accounts.py deleted file mode 100644 index 309c0f2..0000000 --- a/tests/test_accounts.py +++ /dev/null @@ -1,618 +0,0 @@ -""" -资产总览功能 - 全面测试脚本 -测试覆盖:账户 CRUD、余额更新、变更历史、分期计划 CRUD、还款操作 -""" -import requests -import sys -from datetime import date - -BASE_URL = "http://localhost:23994/api" - -passed = 0 -failed = 0 -errors = [] - - -def test(name, condition, detail=""): - global passed, failed - if condition: - passed += 1 - print(f" [PASS] {name}") - else: - failed += 1 - errors.append(name) - print(f" [FAIL] {name} {detail}") - - -def section(title): - print(f"\n{'='*60}") - print(f" {title}") - print(f"{'='*60}") - - -# ============================================================ -section("1. 账户 CRUD 测试") -# ============================================================ - -# 1.1 创建存款账户 - 微信 -print("\n--- 创建存款账户 ---") -r = requests.post(f"{BASE_URL}/accounts", json={ - "name": "微信", - "account_type": "savings", - "balance": 5800.50, - "icon": "wechat", - "color": "#67C23A", - "is_active": True, - "description": "日常零钱" -}) -test("创建微信账户", r.status_code == 201, f"status={r.status_code}") -wechat = r.json() -test("微信账户ID存在", wechat.get("id") is not None) -test("微信余额正确", wechat.get("balance") == 5800.50) -test("微信类型正确", wechat.get("account_type") == "savings") -wechat_id = wechat["id"] - -# 1.2 创建存款账户 - 支付宝 -r = requests.post(f"{BASE_URL}/accounts", json={ - "name": "支付宝", - "account_type": "savings", - "balance": 12300.00, - "icon": "alipay", - "color": "#1677FF", - "is_active": True, - "description": "工资卡" -}) -test("创建支付宝账户", r.status_code == 201, f"status={r.status_code}") -alipay = r.json() -alipay_id = alipay["id"] - -# 1.3 创建存款账户 - 银行卡 -r = requests.post(f"{BASE_URL}/accounts", json={ - "name": "招商银行", - "account_type": "savings", - "balance": 45600.00, - "icon": "bank", - "color": "#FF6B6B", - "is_active": True -}) -test("创建招商银行账户", r.status_code == 201, f"status={r.status_code}") -bank = r.json() -bank_id = bank["id"] - -# 1.4 创建欠款账户 - 花呗 -r = requests.post(f"{BASE_URL}/accounts", json={ - "name": "花呗", - "account_type": "debt", - "balance": 3000.00, - "icon": "credit-card", - "color": "#FFB347", - "is_active": True, - "description": "分3期,每月12号还" -}) -test("创建花呗账户", r.status_code == 201, f"status={r.status_code}") -huabei = r.json() -huabei_id = huabei["id"] - -# 1.5 创建欠款账户 - 白条 -r = requests.post(f"{BASE_URL}/accounts", json={ - "name": "白条", - "account_type": "debt", - "balance": 2000.00, - "icon": "ticket", - "color": "#E6A23C", - "is_active": True, - "description": "分6期,每月15号还" -}) -test("创建白条账户", r.status_code == 201, f"status={r.status_code}") -baitiao = r.json() -baitiao_id = baitiao["id"] - -# 1.6 创建一个已禁用的账户 -r = requests.post(f"{BASE_URL}/accounts", json={ - "name": "已注销信用卡", - "account_type": "debt", - "balance": 0, - "icon": "credit-card", - "color": "#909399", - "is_active": False, - "description": "测试禁用状态" -}) -test("创建已禁用账户", r.status_code == 201) -disabled_id = r.json()["id"] - - -# ============================================================ -section("2. 获取账户列表测试") -# ============================================================ - -print("\n--- 获取所有账户 ---") -r = requests.get(f"{BASE_URL}/accounts") -test("获取账户列表 200", r.status_code == 200, f"status={r.status_code}") -accounts = r.json() -test("账户总数正确", len(accounts) >= 5, f"实际数量: {len(accounts)}") - -savings = [a for a in accounts if a["account_type"] == "savings" and a["is_active"]] -debt = [a for a in accounts if a["account_type"] == "debt" and a["is_active"]] -test("活跃存款账户数量", len(savings) == 3, f"实际: {len(savings)}") -test("活跃欠款账户数量", len(debt) == 2, f"实际: {len(debt)}") - -# 2.1 获取单个账户 -print("\n--- 获取单个账户 ---") -r = requests.get(f"{BASE_URL}/accounts/{wechat_id}") -test("获取单个微信账户 200", r.status_code == 200) -test("账户名称正确", r.json().get("name") == "微信") - -# 2.2 获取不存在的账户 -r = requests.get(f"{BASE_URL}/accounts/99999") -test("获取不存在账户 404", r.status_code == 404, f"status={r.status_code}") - - -# ============================================================ -section("3. 更新账户测试") -# ============================================================ - -print("\n--- 更新账户信息 ---") -r = requests.put(f"{BASE_URL}/accounts/{wechat_id}", json={ - "description": "日常零钱+红包" -}) -test("更新微信描述 200", r.status_code == 200, f"status={r.status_code}") -test("描述更新成功", r.json().get("description") == "日常零钱+红包") - -# 3.1 确认 balance 字段被忽略 -r = requests.put(f"{BASE_URL}/accounts/{wechat_id}", json={ - "balance": 99999 # 应该被忽略 -}) -test("更新忽略 balance", r.json().get("balance") == 5800.50, - f"余额不应被修改, 实际: {r.json().get('balance')}") - - -# ============================================================ -section("4. 余额更新与变更历史测试") -# ============================================================ - -print("\n--- 更新微信余额 ---") -r = requests.post(f"{BASE_URL}/accounts/{wechat_id}/balance", json={ - "new_balance": 6800.50, - "note": "收到红包" -}) -test("更新微信余额 200", r.status_code == 200, f"status={r.status_code}") -test("微信余额更新成功", r.json().get("balance") == 6800.50) - -print("\n--- 再次更新微信余额 ---") -r = requests.post(f"{BASE_URL}/accounts/{wechat_id}/balance", json={ - "new_balance": 5300.50, - "note": "日常消费" -}) -test("微信消费后余额", r.json().get("balance") == 5300.50) - -print("\n--- 更新支付宝余额 ---") -r = requests.post(f"{BASE_URL}/accounts/{alipay_id}/balance", json={ - "new_balance": 15300.00, - "note": "工资到账" -}) -test("支付宝工资到账 200", r.status_code == 200) -test("支付宝余额正确", r.json().get("balance") == 15300.00) - -print("\n--- 更新招商银行余额 ---") -r = requests.post(f"{BASE_URL}/accounts/{bank_id}/balance", json={ - "new_balance": 40000.00, - "note": "取现" -}) -test("招商银行取现 200", r.status_code == 200) - -print("\n--- 更新花呗余额 ---") -r = requests.post(f"{BASE_URL}/accounts/{huabei_id}/balance", json={ - "new_balance": 2500.00, - "note": "提前还款500" -}) -test("花呗提前还款 200", r.status_code == 200) -test("花呗余额更新", r.json().get("balance") == 2500.00) - -# ============================================================ -section("5. 变更历史测试") -# ============================================================ - -print("\n--- 查看微信变更历史 ---") -r = requests.get(f"{BASE_URL}/accounts/{wechat_id}/history", params={ - "page": 1, - "page_size": 10 -}) -test("获取微信历史 200", r.status_code == 200, f"status={r.status_code}") -history = r.json() -test("历史总数正确", history.get("total") == 2, f"实际: {history.get('total')}") -test("分页参数正确", history.get("page") == 1 and history.get("page_size") == 10) - -if history.get("records"): - first_record = history["records"][0] - test("最新记录是消费", first_record.get("change_amount") == -1500.0, - f"实际: {first_record.get('change_amount')}") - test("消费前余额正确", first_record.get("balance_before") == 6800.50) - test("消费后余额正确", first_record.get("balance_after") == 5300.50) - test("消费备注正确", first_record.get("note") == "日常消费") - test("记录有创建时间", first_record.get("created_at") is not None) - -print("\n--- 查看支付宝变更历史 ---") -r = requests.get(f"{BASE_URL}/accounts/{alipay_id}/history") -history_alipay = r.json() -test("支付宝历史 200", r.status_code == 200) -test("支付宝历史有记录", history_alipay.get("total") == 1) - -print("\n--- 查看从未变更的账户历史 ---") -r = requests.get(f"{BASE_URL}/accounts/{baitiao_id}/history") -test("白条历史 200", r.status_code == 200) -test("白条无变更记录", r.json().get("total") == 0) - - -# ============================================================ -section("6. 分期还款计划 CRUD 测试") -# ============================================================ - -# 6.1 为花呗创建分期计划(3期,每月12号,每期1000) -print("\n--- 创建花呗分期计划 ---") -r = requests.post(f"{BASE_URL}/debt-installments", json={ - "account_id": huabei_id, - "total_amount": 3000.00, - "total_periods": 3, - "current_period": 1, - "payment_day": 12, - "payment_amount": 1000.00, - "start_date": "2026-03-12", - "is_completed": False -}) -test("创建花呗分期 201", r.status_code == 201, f"status={r.status_code}") -huabei_inst = r.json() -test("花呗分期ID存在", huabei_inst.get("id") is not None) -test("花呗下次还款日期已计算", huabei_inst.get("next_payment_date") is not None) -test("花呗距今天数已计算", huabei_inst.get("days_until_payment") is not None) -test("花呗剩余期数", huabei_inst.get("remaining_periods") == 3) -test("花呗账户名称关联", huabei_inst.get("account_name") == "花呗") -huabei_inst_id = huabei_inst["id"] - -# 6.2 为白条创建分期计划(6期,每月15号,每期333.33) -print("\n--- 创建白条分期计划 ---") -r = requests.post(f"{BASE_URL}/debt-installments", json={ - "account_id": baitiao_id, - "total_amount": 2000.00, - "total_periods": 6, - "current_period": 3, - "payment_day": 15, - "payment_amount": 333.33, - "start_date": "2026-01-15", - "is_completed": False -}) -test("创建白条分期 201", r.status_code == 201, f"status={r.status_code}") -baitiao_inst = r.json() -test("白条分期第3期", baitiao_inst.get("current_period") == 3) -test("白条剩余期数", baitiao_inst.get("remaining_periods") == 4) -baitiao_inst_id = baitiao_inst["id"] - -# 6.3 验证不能给存款账户创建分期 -print("\n--- 验证存款账户不能创建分期 ---") -r = requests.post(f"{BASE_URL}/debt-installments", json={ - "account_id": wechat_id, - "total_amount": 1000, - "total_periods": 1, - "current_period": 1, - "payment_day": 1, - "payment_amount": 1000, - "start_date": "2026-04-01", - "is_completed": False -}) -test("存款账户不能分期 400", r.status_code == 400, f"status={r.status_code}") - -# 6.4 验证不存在的账户 -r = requests.post(f"{BASE_URL}/debt-installments", json={ - "account_id": 99999, - "total_amount": 1000, - "total_periods": 1, - "current_period": 1, - "payment_day": 1, - "payment_amount": 1000, - "start_date": "2026-04-01", - "is_completed": False -}) -test("不存在账户不能分期 404", r.status_code == 404, f"status={r.status_code}") - - -# ============================================================ -section("7. 获取分期计划列表测试") -# ============================================================ - -print("\n--- 获取所有分期计划 ---") -r = requests.get(f"{BASE_URL}/debt-installments") -test("获取分期列表 200", r.status_code == 200, f"status={r.status_code}") -installments = r.json() -test("分期计划总数 >= 2", len(installments) >= 2, f"实际: {len(installments)}") - -# 验证排序:未完成的排前面,临近的排前面 -if len(installments) >= 2: - first_active = next((i for i in installments if not i["is_completed"]), None) - test("列表第一个是未完成的", first_active is not None) - if first_active: - test("第一个有还款日期", first_active.get("next_payment_date") is not None) - test("第一个有距今天数", first_active.get("days_until_payment") is not None) - -# 验证每个计划都有计算字段 -for inst in installments: - test(f"分期#{inst['id']}有计算字段", - inst.get("next_payment_date") is not None and - inst.get("days_until_payment") is not None and - inst.get("remaining_periods") is not None, - f"id={inst['id']}") - - -# ============================================================ -section("8. 更新分期计划测试") -# ============================================================ - -print("\n--- 更新花呗分期计划 ---") -r = requests.put(f"{BASE_URL}/debt-installments/{huabei_inst_id}", json={ - "total_amount": 3500.00, - "payment_amount": 1166.67 -}) -test("更新花呗分期 200", r.status_code == 200, f"status={r.status_code}") -updated = r.json() -test("花呗总额更新", updated.get("total_amount") == 3500.00) -test("花呗每期金额更新", updated.get("payment_amount") == 1166.67) - -# 恢复 -requests.put(f"{BASE_URL}/debt-installments/{huabei_inst_id}", json={ - "total_amount": 3000.00, - "payment_amount": 1000.00 -}) - - -# ============================================================ -section("9. 标记还款测试(核心流程)") -# ============================================================ - -print("\n--- 花呗还款第1期 ---") -r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay") -test("花呗还款第1期 200", r.status_code == 200, f"status={r.status_code}") -paid = r.json() -test("当前期数变为2", paid.get("current_period") == 2, f"实际: {paid.get('current_period')}") -test("未完成", paid.get("is_completed") == False) -test("剩余期数变为2", paid.get("remaining_periods") == 2) - -print("\n--- 花呗还款第2期 ---") -r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay") -test("花呗还款第2期 200", r.status_code == 200) -paid = r.json() -test("当前期数变为3", paid.get("current_period") == 3) -test("剩余期数变为1", paid.get("remaining_periods") == 1) - -print("\n--- 花呗还款第3期(最后一期)---") -r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay") -test("花呗还款第3期 200", r.status_code == 200) -paid = r.json() -test("标记为已完成", paid.get("is_completed") == True) -test("当前期数等于总期数", paid.get("current_period") == 3) -test("剩余期数为0", paid.get("remaining_periods") == 0) - -print("\n--- 已完成的分期不能继续还款 ---") -r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay") -test("已完成分期拒绝还款 400", r.status_code == 400, f"status={r.status_code}") - - -# ============================================================ -section("10. 删除操作测试") -# ============================================================ - -# 10.1 删除分期计划 -print("\n--- 删除白条分期计划 ---") -r = requests.delete(f"{BASE_URL}/debt-installments/{baitiao_inst_id}") -test("删除白条分期 200", r.status_code == 200, f"status={r.status_code}") - -# 验证删除后列表中没有了 -r = requests.get(f"{BASE_URL}/debt-installments") -ids = [i["id"] for i in r.json()] -test("白条分期已从列表移除", baitiao_inst_id not in ids) - -# 10.2 删除已禁用账户 -print("\n--- 删除已禁用账户 ---") -r = requests.delete(f"{BASE_URL}/accounts/{disabled_id}") -test("删除已禁用账户 200", r.status_code == 200) - -# 10.3 验证删除后 -r = requests.get(f"{BASE_URL}/accounts") -ids = [a["id"] for a in r.json()] -test("已禁用账户已移除", disabled_id not in ids) - -# 10.4 重新创建一个花呗分期用来测试级联删除 -print("\n--- 测试级联删除 ---") -r = requests.post(f"{BASE_URL}/debt-installments", json={ - "account_id": huabei_id, - "total_amount": 1000.00, - "total_periods": 1, - "current_period": 1, - "payment_day": 25, - "payment_amount": 1000.00, - "start_date": "2026-03-25", - "is_completed": False -}) -cascade_inst_id = r.json()["id"] - -r = requests.delete(f"{BASE_URL}/accounts/{huabei_id}") -test("级联删除花呗账户 200", r.status_code == 200) - -# 验证分期计划也被删除了 -r = requests.get(f"{BASE_URL}/debt-installments") -ids = [i["id"] for i in r.json()] -test("花呗关联分期也被删除", cascade_inst_id not in ids) - -# 验证历史记录也被删除 -r = requests.get(f"{BASE_URL}/accounts/{huabei_id}/history") -test("花呗账户删除后历史不可访问", r.status_code == 404, f"status={r.status_code}") - - -# ============================================================ -section("11. 边界条件测试") -# ============================================================ - -print("\n--- 余额更新到0 ---") -r = requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={ - "new_balance": 0, - "note": "还清" -}) -test("余额归零 200", r.status_code == 200) -test("余额为0", r.json().get("balance") == 0) - -print("\n--- 余额更新到大额 ---") -r = requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={ - "new_balance": 999999.99, - "note": "测试大额" -}) -test("大额余额 200", r.status_code == 200) -test("大额余额正确", r.json().get("balance") == 999999.99) - -# 恢复 -requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={ - "new_balance": 2000.00, - "note": "恢复" -}) - -print("\n--- 参数校验:缺失必填字段 ---") -r = requests.post(f"{BASE_URL}/accounts", json={ - "name": "", -}) -test("空名称被拒绝 422", r.status_code == 422, f"status={r.status_code}") - -print("\n--- 参数校验:无效类型 ---") -r = requests.post(f"{BASE_URL}/accounts", json={ - "name": "测试账户", - "account_type": "invalid_type", -}) -test("无效类型被拒绝 422", r.status_code == 422, f"status={r.status_code}") - -print("\n--- 分期参数校验 ---") -r = requests.post(f"{BASE_URL}/debt-installments", json={ - "account_id": baitiao_id, - "total_amount": 0, - "total_periods": 1, - "current_period": 1, - "payment_day": 1, - "payment_amount": 0, - "start_date": "2026-04-01", - "is_completed": False -}) -test("零金额分期被拒绝 422", r.status_code == 422, f"status={r.status_code}") - - -# ============================================================ -section("12. 账户列表的分期信息附加测试") -# ============================================================ - -print("\n--- 获取白条账户(验证分期信息附加) ---") -# 先给白条创建一个分期 -r = requests.post(f"{BASE_URL}/debt-installments", json={ - "account_id": baitiao_id, - "total_amount": 2000.00, - "total_periods": 6, - "current_period": 2, - "payment_day": 15, - "payment_amount": 333.33, - "start_date": "2026-01-15", - "is_completed": False -}) -test("白条分期重建 201", r.status_code == 201) - -r = requests.get(f"{BASE_URL}/accounts") -accounts = r.json() -baitiao_acc = next((a for a in accounts if a["id"] == baitiao_id), None) -test("白条账户有分期附加信息", baitiao_acc is not None and baitiao_acc.get("installments") is not None) - -# 存款账户不应有分期信息 -wechat_acc = next((a for a in accounts if a["id"] == wechat_id), None) -if wechat_acc: - test("微信账户有分期字段(空列表)", wechat_acc.get("installments") is not None) - test("微信分期列表为空", len(wechat_acc.get("installments", [])) == 0) - - -# ============================================================ -section("13. 历史分页测试") -# ============================================================ - -print("\n--- 历史分页 ---") -# 先给招商银行做多次变更 -for i, (bal, note) in enumerate([ - (45000, "存入"), - (42000, "消费"), - (50000, "转入"), - (48000, "理财"), - (52000, "收益"), -]): - requests.post(f"{BASE_URL}/accounts/{bank_id}/balance", json={ - "new_balance": bal, - "note": note - }) - -r = requests.get(f"{BASE_URL}/accounts/{bank_id}/history", params={"page": 1, "page_size": 3}) -test("历史分页第1页 200", r.status_code == 200) -page1 = r.json() -test("每页3条", len(page1.get("records", [])) == 3, f"实际: {len(page1.get('records', []))}") -test("总记录6条", page1.get("total") == 6, f"实际: {page1.get('total')}") - -r = requests.get(f"{BASE_URL}/accounts/{bank_id}/history", params={"page": 2, "page_size": 3}) -test("历史分页第2页 200", r.status_code == 200) -page2 = r.json() -test("第2页3条", len(page2.get("records", [])) == 3, f"实际: {len(page2.get('records', []))}") - -# 验证第1页和第2页没有重复 -page1_ids = {r["id"] for r in page1["records"]} -page2_ids = {r["id"] for r in page2["records"]} -test("两页记录不重复", len(page1_ids & page2_ids) == 0) - - -# ============================================================ -section("14. 花呗还款日期计算验证") -# ============================================================ - -print("\n--- 验证还款日期计算 ---") -today = date.today() -r = requests.get(f"{BASE_URL}/debt-installments") -for inst in r.json(): - if inst["is_completed"]: - continue - next_date_str = inst.get("next_payment_date") - days_until = inst.get("days_until_payment") - if next_date_str: - next_date = date.fromisoformat(next_date_str) - expected_days = (next_date - today).days - test(f"分期#{inst['id']}天数计算正确", days_until == expected_days, - f"计算: {days_until}, 预期: {expected_days}") - - -# ============================================================ -section("15. 保留测试数据(跳过清理)") -# ============================================================ - -print("\n--- 保留测试数据供页面展示 ---") -r = requests.get(f"{BASE_URL}/accounts") -remaining_accounts = len(r.json()) -test("账户数据已保留", remaining_accounts > 0, f"保留: {remaining_accounts} 个账户") - -r = requests.get(f"{BASE_URL}/debt-installments") -remaining_installs = len(r.json()) -test("分期数据已保留", remaining_installs >= 0, f"保留: {remaining_installs} 个分期") - - -# ============================================================ -# 最终报告 -# ============================================================ -print(f"\n{'='*60}") -print(f" 测试报告") -print(f"{'='*60}") -print(f" 通过: {passed}") -print(f" 失败: {failed}") -print(f" 总计: {passed + failed}") -if errors: - print(f"\n 失败项:") - for e in errors: - print(f" - {e}") -print(f"{'='*60}") - -if failed > 0: - sys.exit(1) -else: - print("\n 全部测试通过!") - sys.exit(0)