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)
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 `<Edit />`, `<Delete />` 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 <token>` 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)

View File

@@ -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 (单例)
```

View File

@@ -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 () => {
</main>
</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
type="primary"
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
}
// ============ 资产账户相关 ============
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>
<span>纪念日</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'assets' }"
@click="router.push('/assets')"
>
<el-icon><Wallet /></el-icon>
<span>资产</span>
</button>
</nav>
<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'),
meta: { title: '纪念日', view: 'anniversaries' }
},
{
path: '/assets',
name: 'assets',
component: () => import('@/views/AssetPage.vue'),
meta: { title: '资产总览', view: 'assets' }
},
{
path: '/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 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
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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",
]

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

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)