refactor: remove all asset/account functionality (models, schemas, routers, store, views, components, tests, docs)

This commit is contained in:
祀梦
2026-05-17 12:59:52 +08:00
parent 9c5ef36fe8
commit e3f73048a7
21 changed files with 11 additions and 3904 deletions

View File

@@ -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) # Type-check frontend (noEmit; uses project references tsconfig)
cd WebUI; npm run typecheck 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. **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. - **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`. `set_default_password()` auto-initializes password to `"elysia"` on first access if `password_hash` is empty. - **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. - **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). - **`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`. - **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. - **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) - `Category`: refuses deletion if tasks are linked (400)
- `HabitGroup`: sets linked habits' `group_id` to NULL - `HabitGroup`: sets linked habits' `group_id` to NULL
- `AnniversaryCategory`: sets linked anniversaries' `category_id` to NULL - `AnniversaryCategory`: sets linked anniversaries' `category_id` to NULL
- `FinancialAccount`: full cascade delete (removes linked history + installments)
### Router registration quirks ### 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`. - **`/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`). - **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/`) ### 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/`.
- 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 `<Edit />`, `<Delete />` etc. in templates without imports. - 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. - **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. - `docker-compose.yml` mounts `api/data/` and `api/logs/` for persistence; `api/webui/` is read-only.
## Testing quirks ## 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`. - No test framework or test files currently in the repo.
- 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 <token>` in requests, or temporarily comment out the auth middleware for testing.
- No test coverage for tasks, habits, anniversaries, or tags. - No test coverage for tasks, habits, anniversaries, or tags.
## What's missing (agents should not assume) ## What's missing (agents should not assume)

View File

@@ -1,6 +1,6 @@
# Elysia ToDo - 爱莉希雅待办事项 # Elysia ToDo - 爱莉希雅待办事项
一款全栈个人信息管理应用,集待办任务、习惯打卡、纪念日提醒、资产总览于一体。 一款全栈个人信息管理应用,集待办任务、习惯打卡、纪念日提醒于一体。
## 功能概览 ## 功能概览
@@ -20,12 +20,6 @@
- 支持农历/公历日期 - 支持农历/公历日期
- 倒计时提醒,不错过重要日子 - 倒计时提醒,不错过重要日子
### 资产总览
- 财务账户管理(现金、银行卡、电子钱包等)
- 收支记录与历史查询
- 分期还款跟踪
- 资产汇总统计
### 系统功能 ### 系统功能
- 偏好设置(站点名称、默认视图等) - 偏好设置(站点名称、默认视图等)
- 可折叠侧边栏 - 可折叠侧边栏
@@ -130,7 +124,6 @@ npm run dev
| 标签 | `/api/tags` | 标签 CRUD | | 标签 | `/api/tags` | 标签 CRUD |
| 习惯 | `/api/habits` | 习惯、习惯组、打卡记录 | | 习惯 | `/api/habits` | 习惯、习惯组、打卡记录 |
| 纪念日 | `/api/anniversaries` | 纪念日、纪念日分类 | | 纪念日 | `/api/anniversaries` | 纪念日、纪念日分类 |
| 资产 | `/api/accounts` | 账户、交易记录、分期还款 |
| 设置 | `/api/user-settings` | 用户偏好设置 | | 设置 | `/api/user-settings` | 用户偏好设置 |
| 健康检查 | `/health` | 服务状态检查 | | 健康检查 | `/health` | 服务状态检查 |
@@ -143,9 +136,6 @@ Category ──< Task >── Tag
AnniversaryCategory ──< Anniversary AnniversaryCategory ──< Anniversary
FinancialAccount ──< AccountHistory
──< DebtInstallment
UserSettings (单例) UserSettings (单例)
``` ```

View File

@@ -24,7 +24,7 @@ const authStore = useAuthStore()
// 路由变化时同步 currentView // 路由变化时同步 currentView
watch(() => route.meta.view, (view) => { watch(() => route.meta.view, (view) => {
if (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 }) }, { immediate: true })
@@ -89,7 +89,7 @@ onMounted(async () => {
</main> </main>
</div> </div>
<div v-if="uiStore.currentView !== 'settings' && uiStore.currentView !== 'profile' && uiStore.currentView !== 'habits' && uiStore.currentView !== 'anniversaries' && uiStore.currentView !== 'assets'" class="fab-container"> <div v-if="uiStore.currentView !== 'settings' && uiStore.currentView !== 'profile' && uiStore.currentView !== 'habits' && uiStore.currentView !== 'anniversaries'" class="fab-container">
<el-button <el-button
type="primary" type="primary"
circle circle

View File

@@ -1,64 +0,0 @@
import { get, post, put, del, patch } from './request'
import type {
FinancialAccount, AccountFormData, BalanceUpdateData,
AccountHistoryResponse, DebtInstallment, DebtInstallmentFormData
} from './types'
export interface GetAccountHistoryParams {
page?: number
page_size?: number
}
export const accountApi = {
// ============ 账户 CRUD ============
getAccounts(): Promise<FinancialAccount[]> {
return get<FinancialAccount[]>('/accounts')
},
getAccount(id: number): Promise<FinancialAccount> {
return get<FinancialAccount>(`/accounts/${id}`)
},
createAccount(data: AccountFormData): Promise<FinancialAccount> {
return post<FinancialAccount>('/accounts', data)
},
updateAccount(id: number, data: Partial<AccountFormData>): Promise<FinancialAccount> {
return put<FinancialAccount>(`/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<FinancialAccount> {
return post<FinancialAccount>(`/accounts/${id}/balance`, data)
},
// ============ 变更历史 ============
getHistory(id: number, params?: GetAccountHistoryParams): Promise<AccountHistoryResponse> {
return get<AccountHistoryResponse>(`/accounts/${id}/history`, { params })
},
// ============ 分期计划 ============
getInstallments(): Promise<DebtInstallment[]> {
return get<DebtInstallment[]>('/debt-installments')
},
createInstallment(data: DebtInstallmentFormData): Promise<DebtInstallment> {
return post<DebtInstallment>('/debt-installments', data)
},
updateInstallment(id: number, data: Partial<DebtInstallmentFormData>): Promise<DebtInstallment> {
return put<DebtInstallment>(`/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<DebtInstallment> {
return patch<DebtInstallment>(`/debt-installments/${id}/pay`)
},
}

View File

@@ -186,93 +186,4 @@ export interface AnniversaryFormData {
remind_days_before: number 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
}

View File

@@ -1,361 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editAccount?: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
editAccount: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const isEdit = computed(() => !!props.editAccount)
const dialogTitle = computed(() => isEdit.value ? '编辑账户' : '新建账户')
const form = ref({
name: '',
account_type: 'savings' as 'savings' | 'debt',
balance: 0,
icon: 'wallet',
color: '#FFB7C5',
is_active: true,
description: ''
})
const iconOptions = [
{ label: '钱包', value: 'wallet' },
{ label: '微信', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银行卡', value: 'bank' },
{ label: '信用卡', value: 'credit-card' },
{ label: '花呗', value: 'huabei' },
{ label: '白条', value: 'baitiao' },
{ label: '现金', value: 'cash' },
{ label: '投资', value: 'investment' },
{ label: '其他', value: 'other' },
]
const colorOptions = [
'#FFB7C5', '#C8A2C8', '#98D8C8', '#FFB347',
'#87CEEB', '#FF6B6B', '#A8E6CF', '#DDA0DD',
'#F0E68C', '#20B2AA', '#FF8C69', '#9370DB',
]
watch(() => props.visible, (val) => {
if (val) {
if (props.editAccount) {
const a = props.editAccount
form.value = {
name: a.name,
account_type: a.account_type,
balance: a.balance,
icon: a.icon,
color: a.color,
is_active: a.is_active,
description: a.description || ''
}
} else {
form.value = {
name: '',
account_type: 'savings',
balance: 0,
icon: 'wallet',
color: '#FFB7C5',
is_active: true,
description: ''
}
}
}
})
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入账户名称~')
return
}
const data = {
name: form.value.name.trim(),
account_type: form.value.account_type,
balance: form.value.balance,
icon: form.value.icon,
color: form.value.color,
sort_order: 0,
is_active: form.value.is_active,
description: form.value.description.trim() || null
}
if (isEdit.value && props.editAccount) {
const result = await store.updateAccount(props.editAccount.id, data)
if (result) ElMessage.success('账户更新成功~')
} else {
const result = await store.createAccount(data)
if (result) ElMessage.success('账户创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="480px"
@close="handleClose"
class="account-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">账户名称</label>
<el-input
v-model="form.name"
placeholder="如:微信、支付宝、花呗、招商银行..."
maxlength="100"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">账户类型</label>
<div class="type-switch">
<button
class="type-btn"
:class="{ active: form.account_type === 'savings' }"
@click="form.account_type = 'savings'"
>
<el-icon><Wallet /></el-icon>
<span>存款</span>
</button>
<button
class="type-btn"
:class="{ active: form.account_type === 'debt' }"
@click="form.account_type = 'debt'"
>
<el-icon><CreditCard /></el-icon>
<span>欠款</span>
</button>
</div>
</div>
<div class="form-item">
<label class="form-label">
当前余额
<span class="form-hint">{{ form.account_type === 'savings' ? '存款金额' : '欠款金额' }}</span>
</label>
<el-input-number
v-model="form.balance"
:precision="2"
:step="100"
:min="0"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">图标</label>
<div class="icon-grid">
<button
v-for="opt in iconOptions"
:key="opt.value"
class="icon-btn"
:class="{ active: form.icon === opt.value }"
@click="form.icon = opt.value"
:title="opt.label"
>
<el-icon :size="18">
<Wallet v-if="opt.value === 'wallet'" />
<ChatDotRound v-else-if="opt.value === 'wechat'" />
<ShoppingCart v-else-if="opt.value === 'alipay'" />
<CreditCard v-else-if="opt.value === 'bank' || opt.value === 'credit-card' || opt.value === 'huabei'" />
<Ticket v-else-if="opt.value === 'baitiao'" />
<Money v-else-if="opt.value === 'cash'" />
<TrendCharts v-else-if="opt.value === 'investment'" />
<MoreFilled v-else />
</el-icon>
</button>
</div>
</div>
<div class="form-item">
<label class="form-label">主题色</label>
<div class="color-grid">
<button
v-for="color in colorOptions"
:key="color"
class="color-btn"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">备注</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="可选的备注信息"
maxlength="500"
/>
</div>
<div v-if="isEdit" class="form-item">
<label class="form-label">启用状态</label>
<el-switch v-model="form.is_active" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
.form-hint {
font-size: 12px;
color: var(--text-secondary);
font-weight: 400;
}
}
}
.type-switch {
display: flex;
gap: 12px;
.type-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: 2px solid rgba(255, 183, 197, 0.2);
border-radius: var(--radius-md);
background: white;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&.active {
border-color: var(--primary);
background: rgba(255, 183, 197, 0.1);
color: var(--primary);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.2);
}
}
}
.icon-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
.icon-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 183, 197, 0.15);
border-radius: var(--radius-sm);
background: white;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&.active {
border-color: var(--primary);
background: rgba(255, 183, 197, 0.1);
color: var(--primary);
}
}
}
.color-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
.color-btn {
width: 28px;
height: 28px;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 2px white, 0 0 0 4px var(--text-primary);
transform: scale(1.1);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -1,235 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount, AccountHistoryRecord } from '@/api/types'
interface Props {
visible: boolean
account: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
account: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const records = ref<AccountHistoryRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
async function fetchHistory() {
if (!props.account) return
loading.value = true
const result = await store.fetchHistory(props.account.id, page.value, pageSize)
records.value = result.records
total.value = result.total
loading.value = false
}
watch(() => props.visible, (val) => {
if (val && props.account) {
page.value = 1
fetchHistory()
}
})
function handlePageChange(newPage: number) {
page.value = newPage
fetchHistory()
}
function formatAmount(amount: number): string {
if (amount > 0) return `+${amount.toFixed(2)}`
return amount.toFixed(2)
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="account ? `${account.name} - 变更历史` : '变更历史'"
width="600px"
@close="handleClose"
class="history-dialog"
>
<div class="history-content">
<div v-if="loading" class="loading-state">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="records.length === 0" class="empty-state">
<el-icon :size="40" color="#C8A2C8"><Document /></el-icon>
<p>暂无变更记录</p>
</div>
<div v-else class="history-list">
<div
v-for="record in records"
:key="record.id"
class="history-item"
>
<div class="history-left">
<div
class="amount-badge"
:class="{ positive: record.change_amount > 0, negative: record.change_amount < 0 }"
>
<el-icon v-if="record.change_amount > 0"><Top /></el-icon>
<el-icon v-else-if="record.change_amount < 0"><Bottom /></el-icon>
<el-icon v-else><Minus /></el-icon>
<span>{{ formatAmount(record.change_amount) }}</span>
</div>
<div class="history-info">
<span class="history-note">{{ record.note || '未备注' }}</span>
<span class="history-date">{{ formatDate(record.created_at) }}</span>
</div>
</div>
<div class="history-right">
<span class="balance-change">
{{ record.balance_before.toFixed(2) }}
<el-icon :size="12"><Right /></el-icon>
{{ record.balance_after.toFixed(2) }}
</span>
</div>
</div>
</div>
<div v-if="total > pageSize" class="pagination-wrapper">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.history-content {
min-height: 200px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 0;
color: var(--text-secondary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 0;
color: var(--text-secondary);
p {
margin: 0;
font-size: 14px;
}
}
.history-list {
display: flex;
flex-direction: column;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px dashed rgba(255, 183, 197, 0.15);
&:last-child {
border-bottom: none;
}
}
.history-left {
display: flex;
align-items: center;
gap: 14px;
}
.amount-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
&.positive {
color: #67C23A;
background: rgba(103, 194, 58, 0.1);
}
&.negative {
color: #F56C6C;
background: rgba(245, 108, 108, 0.1);
}
}
.history-info {
display: flex;
flex-direction: column;
gap: 2px;
.history-note {
font-size: 14px;
color: var(--text-primary);
}
.history-date {
font-size: 12px;
color: var(--text-secondary);
}
}
.history-right {
.balance-change {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
padding-top: 16px;
}
</style>

View File

@@ -95,14 +95,6 @@ const currentRouteName = computed(() => route.name as string)
<el-icon><Cherry /></el-icon> <el-icon><Cherry /></el-icon>
<span>纪念日</span> <span>纪念日</span>
</button> </button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'assets' }"
@click="router.push('/assets')"
>
<el-icon><Wallet /></el-icon>
<span>资产</span>
</button>
</nav> </nav>
<div class="header-right"> <div class="header-right">

View File

@@ -1,185 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
account: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
account: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const newBalance = ref(0)
const note = ref('')
const saving = ref(false)
const changeAmount = computed(() => {
if (!props.account) return 0
return Math.round((newBalance.value - props.account.balance) * 100) / 100
})
const changeText = computed(() => {
const diff = changeAmount.value
if (diff > 0) return `+${diff.toFixed(2)}`
if (diff < 0) return diff.toFixed(2)
return '0.00'
})
const isPositive = computed(() => changeAmount.value >= 0)
watch(() => props.visible, (val) => {
if (val && props.account) {
newBalance.value = props.account.balance
note.value = ''
saving.value = false
}
})
async function handleSave() {
if (!props.account) return
saving.value = true
try {
const result = await store.updateBalance(props.account.id, {
new_balance: newBalance.value,
note: note.value.trim() || null
})
if (result) {
ElMessage.success('余额更新成功~')
emit('update:visible', false)
}
} finally {
saving.value = false
}
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
title="更新余额"
width="420px"
@close="handleClose"
class="balance-dialog"
>
<div class="form-content">
<div v-if="account" class="balance-preview">
<span class="current-label">{{ account.name }}</span>
<span class="current-balance">{{ account.balance.toFixed(2) }}</span>
</div>
<div class="change-indicator" :class="{ positive: isPositive, negative: !isPositive }">
<el-icon><Top v-if="isPositive" /><Bottom v-else /></el-icon>
<span>{{ changeText }}</span>
</div>
<div class="form-item">
<label class="form-label">新余额</label>
<el-input-number
v-model="newBalance"
:precision="2"
:step="100"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">备注</label>
<el-input
v-model="note"
placeholder="如:工资到账、还花呗、日常消费..."
maxlength="200"
show-word-limit
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">
确认更新
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.balance-preview {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255, 183, 197, 0.08);
border-radius: var(--radius-md);
.current-label {
font-size: 14px;
color: var(--text-secondary);
}
.current-balance {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
}
.change-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border-radius: var(--radius-md);
font-size: 16px;
font-weight: 600;
&.positive {
color: #67C23A;
background: rgba(103, 194, 58, 0.1);
}
&.negative {
color: #F56C6C;
background: rgba(245, 108, 108, 0.1);
}
}
.form-item {
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -1,272 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { DebtInstallment, FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
interface Props {
visible: boolean
editInstallment?: DebtInstallment | null
accountId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
editInstallment: null,
accountId: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const isEdit = computed(() => !!props.editInstallment)
const dialogTitle = computed(() => isEdit.value ? '编辑分期计划' : '新建分期计划')
const form = ref({
account_id: null as number | null,
total_amount: 0,
total_periods: 3,
current_period: 1,
payment_day: 12,
payment_amount: 0,
start_date: '',
is_completed: false
})
const debtAccounts = computed(() => store.debtAccounts)
watch(() => props.visible, (val) => {
if (val) {
if (props.editInstallment) {
const inst = props.editInstallment
form.value = {
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
}
} else {
form.value = {
account_id: props.accountId || (debtAccounts.value.length > 0 ? debtAccounts.value[0].id : null),
total_amount: 0,
total_periods: 3,
current_period: 1,
payment_day: 12,
payment_amount: 0,
start_date: '',
is_completed: false
}
}
}
})
watch([() => form.value.total_amount, () => form.value.total_periods], ([amount, periods]) => {
if (!isEdit.value && amount > 0 && periods > 0) {
form.value.payment_amount = Math.round((amount / periods) * 100) / 100
}
})
async function handleSave() {
if (!form.value.account_id) {
ElMessage.warning('请选择关联的欠款账户~')
return
}
if (form.value.total_amount <= 0) {
ElMessage.warning('请输入分期总额~')
return
}
if (form.value.payment_amount <= 0) {
ElMessage.warning('请输入每期还款金额~')
return
}
if (!form.value.start_date) {
ElMessage.warning('请选择首次还款日期~')
return
}
const data = {
account_id: form.value.account_id,
total_amount: form.value.total_amount,
total_periods: form.value.total_periods,
current_period: form.value.current_period,
payment_day: form.value.payment_day,
payment_amount: form.value.payment_amount,
start_date: form.value.start_date,
is_completed: form.value.is_completed
}
if (isEdit.value && props.editInstallment) {
const result = await store.updateInstallment(props.editInstallment.id, data)
if (result) ElMessage.success('分期计划更新成功~')
} else {
const result = await store.createInstallment(data)
if (result) ElMessage.success('分期计划创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="460px"
@close="handleClose"
class="installment-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">关联账户</label>
<el-select
v-model="form.account_id"
placeholder="选择欠款账户"
style="width: 100%"
:disabled="isEdit"
>
<el-option
v-for="acc in debtAccounts"
:key="acc.id"
:label="acc.name"
:value="acc.id"
/>
</el-select>
<div v-if="debtAccounts.length === 0" class="form-hint" style="margin-top: 8px;">
暂无欠款账户请先创建一个欠款类型的账户~
</div>
</div>
<div class="form-item">
<label class="form-label">分期总额</label>
<el-input-number
v-model="form.total_amount"
:precision="2"
:step="500"
:min="0"
style="width: 100%"
/>
</div>
<div class="form-item form-row">
<div class="form-col">
<label class="form-label">总期数</label>
<el-input-number
v-model="form.total_periods"
:min="1"
:max="36"
style="width: 100%"
/>
</div>
<div class="form-col">
<label class="form-label">每月还款日</label>
<el-input-number
v-model="form.payment_day"
:min="1"
:max="31"
style="width: 100%"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">每期还款金额</label>
<el-input-number
v-model="form.payment_amount"
:precision="2"
:step="100"
:min="0"
style="width: 100%"
/>
<div v-if="!isEdit && form.total_amount > 0 && form.total_periods > 0" class="form-hint" style="margin-top: 8px;">
自动计算: {{ form.total_amount.toFixed(2) }} / {{ form.total_periods }} = {{ (form.total_amount / form.total_periods).toFixed(2) }}
</div>
</div>
<div v-if="isEdit" class="form-item">
<label class="form-label">当前期数第几期待还</label>
<el-input-number
v-model="form.current_period"
:min="1"
:max="form.total_periods"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">首次还款日期</label>
<el-date-picker
v-model="form.start_date"
type="date"
:locale="zhCn"
placeholder="选择首次还款日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:clearable="false"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
.form-hint {
font-size: 12px;
color: var(--text-secondary);
}
}
.form-row {
display: flex;
gap: 16px;
.form-col {
flex: 1;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -49,12 +49,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/AnniversaryPage.vue'), component: () => import('@/views/AnniversaryPage.vue'),
meta: { title: '纪念日', view: 'anniversaries' } meta: { title: '纪念日', view: 'anniversaries' }
}, },
{
path: '/assets',
name: 'assets',
component: () => import('@/views/AssetPage.vue'),
meta: { title: '资产总览', view: 'assets' }
},
{ {
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',

View File

@@ -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<FinancialAccount[]>([])
const installments = ref<DebtInstallment[]>([])
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<FinancialAccount | null> {
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<AccountFormData>): Promise<FinancialAccount | null> {
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<boolean> {
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<FinancialAccount | null> {
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<AccountHistoryResponse> {
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<DebtInstallment | null> {
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<DebtInstallmentFormData>): Promise<DebtInstallment | null> {
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<boolean> {
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<DebtInstallment | null> {
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,
}
})

View File

@@ -12,7 +12,7 @@ export const useUIStore = defineStore('ui', () => {
const editingCategory = ref<Category | null>(null) const editingCategory = ref<Category | null>(null)
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const globalLoading = 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') const calendarMode = ref<'week' | 'monthly'>('monthly')
function openTaskDialog(task?: Task) { function openTaskDialog(task?: Task) {
@@ -43,7 +43,7 @@ export const useUIStore = defineStore('ui', () => {
globalLoading.value = loading 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 currentView.value = view
} }

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,7 @@ def init_db():
"""初始化数据库表,自动补充新增的列""" """初始化数据库表,自动补充新增的列"""
# 导入所有模型,确保 Base.metadata 包含全部表定义 # 导入所有模型,确保 Base.metadata 包含全部表定义
from app.models import ( # noqa: F401 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) Base.metadata.create_all(bind=engine)

View File

@@ -4,11 +4,9 @@ from app.models.tag import Tag, task_tags
from app.models.user_settings import UserSettings from app.models.user_settings import UserSettings
from app.models.habit import HabitGroup, Habit, HabitCheckin from app.models.habit import HabitGroup, Habit, HabitCheckin
from app.models.anniversary import AnniversaryCategory, Anniversary from app.models.anniversary import AnniversaryCategory, Anniversary
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
__all__ = [ __all__ = [
"Task", "Category", "Tag", "task_tags", "UserSettings", "Task", "Category", "Tag", "task_tags", "UserSettings",
"HabitGroup", "Habit", "HabitCheckin", "HabitGroup", "Habit", "HabitCheckin",
"AnniversaryCategory", "Anniversary", "AnniversaryCategory", "Anniversary",
"FinancialAccount", "AccountHistory", "DebtInstallment",
] ]

View File

@@ -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")

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter 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() api_router = APIRouter()
@@ -10,4 +10,3 @@ api_router.include_router(tags.router)
api_router.include_router(user_settings.router) api_router.include_router(user_settings.router)
api_router.include_router(habits.router) api_router.include_router(habits.router)
api_router.include_router(anniversaries.router) api_router.include_router(anniversaries.router)
api_router.include_router(accounts.router)

View File

@@ -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="分期还款失败")

View File

@@ -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

View File

@@ -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)