fix: 修复账单删除功能并支持分析页面删除操作
Some checks are pending
Deploy BillAI / Deploy to Production (push) Waiting to run

- 将删除接口从 DELETE /api/bills/:id 改为 POST /api/bills/:id/delete 以兼容 SvelteKit 代理
- 分析页面组件 (TopExpenses/BillRecordsTable/DailyTrendChart) 支持删除并同步更新统计数据
- Review 接口改为直接查询 MongoDB 而非读取文件
- 软删除时记录 updated_at 时间戳
- 添加 .dockerignore 文件优化构建
- 完善 AGENTS.md 文档
This commit is contained in:
clz
2026-02-16 22:28:49 +08:00
parent a5f1a370c7
commit 642ea2d3ef
13 changed files with 277 additions and 151 deletions

182
AGENTS.md
View File

@@ -3,139 +3,125 @@
Guidelines for AI coding agents working on BillAI - a microservices bill analysis system.
## Architecture
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript
- `server/` - Go 1.21 + Gin + MongoDB
- `analyzer/` - Python 3.12 + FastAPI
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend Proxy & UI)
- `server/` - Go 1.21 + Gin + MongoDB (Main API & Data Storage)
- `analyzer/` - Python 3.12 + FastAPI (Data Cleaning & Analysis Service)
## Build/Lint/Test Commands
### Frontend (web/)
**Working Directory:** `/Users/clz/Projects/BillAI/web`
```bash
npm run dev # Start dev server
npm run build # Production build
npm run check # TypeScript check
npm run check # TypeScript check (svelte-check)
npm run lint # Prettier + ESLint
npm run format # Format code
npm run test # Run all tests
npx vitest run src/routes/+page.spec.ts # Single test file
npx vitest run -t "test name" # Test by name
npm run format # Format code (Prettier)
npm run test:unit # Run all unit tests (Vitest)
npx vitest run src/routes/+page.spec.ts # Run single test file
npx vitest run -t "test name" # Run test by name pattern
```
### Backend (server/)
**Working Directory:** `/Users/clz/Projects/BillAI/server`
```bash
go run . # Start server
go build . # Build binary
go build -o server . # Build binary
go mod tidy # Clean dependencies
go test ./... # All tests
go test ./handler/... # Package tests
go test -run TestName # Single test
go test ./... # Run all tests
go test ./handler/... # Run package tests
go test -run TestName # Run single test function
go test -v ./handler/... # Run tests with verbose output
```
### Analyzer (analyzer/)
**Working Directory:** `/Users/clz/Projects/BillAI/analyzer`
```bash
python server.py # Start FastAPI server
pytest # All tests
pytest test_file.py # Single file
pytest -k "test_name" # Test by pattern
python server.py # Start FastAPI server directly
uvicorn server:app --reload # Start with hot reload
pytest # Run all tests
pytest test_file.py # Run single test file
pytest -k "test_name" # Run test by name pattern
pip install -r requirements.txt # Install dependencies
```
### Docker
**Working Directory:** `/Users/clz/Projects/BillAI`
```bash
docker-compose up -d --build # Start/rebuild all
docker-compose up -d --build # Start/rebuild all services
docker-compose logs -f server # Follow service logs
docker-compose down # Stop all services
```
## Code Style
### TypeScript/Svelte
**Prettier config:** Tabs, single quotes, no trailing commas, width 100
### General
- **Comments:** Existing comments often use Chinese for business logic explanations. Maintain this style where appropriate, but English is also acceptable for technical explanations.
- **Conventions:** Follow existing patterns strictly. Do not introduce new frameworks or libraries without checking `package.json`/`go.mod`/`requirements.txt`.
**Imports:**
```typescript
import { browser } from '$app/environment'; // SvelteKit
import { auth } from '$lib/stores/auth'; // Internal
import type { UIBill } from '$lib/models/bill';
```
### TypeScript/Svelte (web/)
- **Formatting:** Prettier (Tabs, single quotes, no trailing commas, printWidth 100).
- **Naming:** `PascalCase` for types/components/interfaces, `camelCase` for variables/functions.
- **Imports:** Use `$lib` alias for internal imports.
```typescript
import { browser } from '$app/environment';
import { auth } from '$lib/stores/auth';
import type { UIBill } from '$lib/models/bill';
```
- **Types:** Define interfaces for data models. Use `export interface`.
- **Error Handling:** Check `response.ok`. Throw `Error` with status for UI to catch.
**Types:**
```typescript
export interface UploadResponse {
result: boolean;
message: string;
data?: UploadData;
}
```
**Naming:** PascalCase (types, components), camelCase (functions, variables)
**Error handling:**
```typescript
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Handle 401 -> logout redirect
```
### Go Backend
**Structure:** `handler/``service/``repository/` → MongoDB
**JSON tags:** snake_case, omitempty for optional fields
```go
type UpdateBillRequest struct {
Category *string `json:"category,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
```
**Response format:**
```go
type Response struct {
Result bool `json:"result"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
}
```
**Error handling:**
```go
if err == repository.ErrNotFound {
c.JSON(http.StatusNotFound, Response{Result: false, Message: "not found"})
### Go Backend (server/)
- **Structure:** `handler` (HTTP) → `service` (Logic) → `repository` (DB) → `model` (Structs).
- **Tags:** Use `json` (snake_case) and `form` tags. Use `omitempty` for optional fields.
```go
type UpdateBillRequest struct {
Category *string `json:"category,omitempty" form:"category"`
}
```
- **Error Handling:** Return `500` for DB errors, `400` for bad requests. Wrap errors with context.
```go
if err != nil {
c.JSON(http.StatusInternalServerError, Response{Result: false, Message: err.Error()})
return
}
```
}
```
### Python Analyzer
**Style:** PEP 8, type hints, Pydantic models
```python
def do_clean(
input_path: str,
output_path: str,
bill_type: str = "auto"
) -> tuple[bool, str, str]:
```
**Error handling:**
```python
if not success:
raise HTTPException(status_code=400, detail=message)
```
### Python Analyzer (analyzer/)
- **Style:** PEP 8. Use `snake_case` for variables/functions.
- **Type Hints:** Mandatory for function arguments and return types.
- **Models:** Use `pydantic.BaseModel` for API schemas.
```python
class CleanRequest(BaseModel):
input_path: str
bill_type: Optional[str] = "auto"
```
- **Docstrings:** Use triple quotes. Chinese descriptions are common for API docs.
## Key Patterns
**API Flow:** Frontend (SvelteKit proxy) → Go API → MongoDB + Python analyzer
- **API Flow:**
- Frontend talks to `server` (Go) via `/api` proxy.
- `server` handles auth, DB operations, and delegates complex file processing to `analyzer` (Python).
- `analyzer` cleanses CSV/Excel files and returns structured JSON/CSV to `server`.
**Auth:** JWT tokens, Bearer header, 401 → logout redirect
- **Authentication:**
- JWT based. Token stored in frontend.
- Header: `Authorization: Bearer <token>`.
- Backend middleware checks token. 401 triggers logout/redirect.
**File Processing:** ZIP → extract → convert (GBK→UTF-8, xlsx→csv) → clean → import
**Testing:** Vitest + Playwright for frontend, Go test for backend
- **File Processing:**
- Flow: Upload (ZIP/XLSX) -> Extract/Convert (to UTF-8 CSV) -> Clean (normalize columns) -> Import to DB.
- `analyzer` uses `openpyxl` for Excel and regex for cleaning text.
## Important Files
- `web/src/lib/api.ts` - API client
- `web/src/lib/models/` - UI data models
- `server/handler/` - HTTP handlers
- `server/service/` - Business logic
- `server/model/` - Go data structures
- `analyzer/cleaners/` - Bill processing
- `mock_data/*.zip` - Test data (password: 123456)
- `web/src/lib/api.ts` - Centralized API client methods.
- `web/src/lib/models/*.ts` - Frontend data models (should match backend JSON).
- `server/handler/*.go` - HTTP endpoint definitions.
- `server/repository/mongo.go` - MongoDB connection and queries.
- `analyzer/server.py` - FastAPI entry point and routing.
- `analyzer/cleaners/*.py` - Specific logic for Alipay/Wechat/JD bills.

13
analyzer/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*$py.class
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.git
.gitignore
.DS_Store

25
server/.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
# Ignore these files for docker build context
# Binaries
server
billai-server
*.exe
*.dll
# Dependencies (if any are local and not in go.mod/go.sum, unlikely for Go)
vendor/
# Logs and outputs
*.log
outputs/
uploads/
# IDE config
.idea/
.vscode/
# Git
.git/
.gitignore
# OS
.DS_Store

View File

@@ -14,7 +14,7 @@ type DeleteBillResponse struct {
Message string `json:"message,omitempty"`
}
// DeleteBill DELETE /api/bills/:id 删除清洗后的账单记录
// DeleteBill POST /api/bills/:id/delete 删除清洗后的账单记录
func DeleteBill(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {

View File

@@ -1,61 +1,59 @@
package handler
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"billai-server/config"
"billai-server/model"
"billai-server/service"
"billai-server/repository"
)
// Review 获取需要复核的记录
func Review(c *gin.Context) {
// 获取文件名参
fileName := c.Query("file")
if fileName == "" {
c.JSON(http.StatusBadRequest, model.ReviewResponse{
// 获取数
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
Result: false,
Message: "请提供文件名参数 (file)",
Message: "数据库未连接",
})
return
}
// 构建文件路径
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
filePath := filepath.Join(outputDirAbs, fileName)
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, model.ReviewResponse{
// 从MongoDB查询所有需要复核的账单
bills, err := repo.GetBillsNeedReview()
if err != nil {
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
Result: false,
Message: "文件不存在: " + fileName,
Message: "查询失败: " + err.Error(),
})
return
}
// 判断文件格式
format := "csv"
if strings.HasSuffix(fileName, ".json") {
format = "json"
}
// 提取需要复核的记录
records := service.ExtractNeedsReview(filePath, format)
// 统计高低优先级数量
// 统计高低优先级数量并转换为 ReviewRecord
highCount := 0
lowCount := 0
for _, r := range records {
if r.ReviewLevel == "HIGH" {
records := make([]model.ReviewRecord, 0, len(bills))
for _, bill := range bills {
if bill.ReviewLevel == "HIGH" {
highCount++
} else if r.ReviewLevel == "LOW" {
} else if bill.ReviewLevel == "LOW" {
lowCount++
}
records = append(records, model.ReviewRecord{
Time: bill.Time.Time().Format("2006-01-02 15:04:05"),
Category: bill.Category,
Merchant: bill.Merchant,
Description: bill.Description,
IncomeExpense: bill.IncomeExpense,
Amount: fmt.Sprintf("%.2f", bill.Amount),
Remark: bill.Remark,
ReviewLevel: bill.ReviewLevel,
})
}
c.JSON(http.StatusOK, model.ReviewResponse{

View File

@@ -445,7 +445,12 @@ func (r *Repository) DeleteCleanedBillByID(id string) error {
defer cancel()
filter := bson.M{"_id": oid}
update := bson.M{"$set": bson.M{"is_deleted": true}}
update := bson.M{
"$set": bson.M{
"is_deleted": true,
"updated_at": time.Now(), // 记录更新时间
},
}
result, err := r.cleanedCollection.UpdateOne(ctx, filter, update)
if err != nil {
return fmt.Errorf("soft delete bill failed: %w", err)

View File

@@ -63,7 +63,7 @@ func setupAPIRoutes(r *gin.Engine) {
authed.POST("/bills/:id", handler.UpdateBill)
// 删除账单(软删除)
authed.DELETE("/bills/:id", handler.DeleteBill)
authed.POST("/bills/:id/delete", handler.DeleteBill)
// 手动创建账单
authed.POST("/bills/manual", handler.CreateManualBills)

5
web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.git
.DS_Store
.svelte-kit
build

View File

@@ -128,17 +128,6 @@ export async function uploadBill(
return response.json();
}
// 获取复核记录
export async function getReviewRecords(fileName: string): Promise<ReviewResponse> {
const response = await apiFetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// 获取月度统计(全部数据,不受筛选条件影响)
export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
const response = await apiFetch(`${API_BASE}/api/monthly-stats`);
@@ -403,8 +392,8 @@ export interface DeleteBillResponse {
// 删除账单(软删除)
export async function deleteBill(id: string): Promise<DeleteBillResponse> {
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
method: 'DELETE'
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}/delete`, {
method: 'POST'
});
if (!response.ok) {

View File

@@ -16,6 +16,7 @@
pageSize?: number;
categories?: string[];
onUpdate?: (updated: UIBill, original: UIBill) => void;
onDelete?: (deleted: UIBill) => void;
}
let {
@@ -24,7 +25,8 @@
showDescription = true,
pageSize = 10,
categories = [],
onUpdate
onUpdate,
onDelete
}: Props = $props();
// 排序状态
@@ -112,6 +114,24 @@
onUpdate?.(updated, original);
}
function handleRecordDeleted(deleted: UIBill) {
const idx = records.findIndex(r => r === deleted);
const finalIdx = idx !== -1
? idx
: records.findIndex(r =>
r.time === deleted.time &&
r.merchant === deleted.merchant &&
r.amount === deleted.amount
);
if (finalIdx !== -1) {
records.splice(finalIdx, 1);
records = [...records];
}
onDelete?.(deleted);
}
// 重置分页(当记录变化时)
$effect(() => {
records;
@@ -280,4 +300,6 @@
viewDescription="查看这笔支出的详细信息"
editDescription="修改这笔支出的信息"
onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
allowDelete={true}
/>

View File

@@ -18,9 +18,10 @@
records: UIBill[];
categories?: string[];
onUpdate?: (updated: UIBill, original: UIBill) => void;
onDelete?: (deleted: UIBill) => void;
}
let { records = $bindable(), categories = [], onUpdate }: Props = $props();
let { records = $bindable(), categories = [], onUpdate, onDelete }: Props = $props();
function handleRecordUpdated(updated: UIBill, original: UIBill) {
// 更新 records 数组
@@ -47,6 +48,28 @@
onUpdate?.(updated, original);
}
function handleRecordDeleted(deleted: UIBill) {
const idx = records.findIndex(r =>
r === deleted ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (idx !== -1) {
records.splice(idx, 1);
records = [...records];
}
const dateIdx = selectedDateRecords.findIndex(r =>
r === deleted ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (dateIdx !== -1) {
selectedDateRecords.splice(dateIdx, 1);
selectedDateRecords = [...selectedDateRecords];
}
onDelete?.(deleted);
}
// Dialog 状态
let dialogOpen = $state(false);
let selectedDate = $state<Date | null>(null);
@@ -923,6 +946,7 @@
pageSize={8}
{categories}
onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
/>
</div>
{:else}

View File

@@ -8,9 +8,10 @@
records: UIBill[];
categories: string[]; // 可用的分类列表
onUpdate?: (record: UIBill) => void;
onDelete?: (record: UIBill) => void;
}
let { records, categories, onUpdate }: Props = $props();
let { records, categories, onUpdate, onDelete }: Props = $props();
let dialogOpen = $state(false);
let selectedRecord = $state<UIBill | null>(null);
@@ -32,6 +33,26 @@
selectedRecord = updated;
onUpdate?.(updated);
}
function handleRecordDeleted(deleted: UIBill) {
const idx = records.findIndex(r => r === deleted);
const finalIdx = idx !== -1
? idx
: records.findIndex(r =>
r.time === deleted.time &&
r.merchant === deleted.merchant &&
r.amount === deleted.amount
);
if (finalIdx !== -1) {
records.splice(finalIdx, 1);
records = [...records];
}
selectedRecord = null;
selectedRank = 0;
onDelete?.(deleted);
}
</script>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
@@ -80,6 +101,8 @@
viewDescription="查看这笔支出的完整信息"
editDescription="修改这笔支出的信息"
onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
allowDelete={true}
>
{#snippet titleExtra({ isEditing })}
{#if selectedRank <= 3 && !isEditing}

View File

@@ -127,6 +127,32 @@
}
}
function handleBillDeleted(deleted: UIBill) {
const idx = records.findIndex(r =>
r.id === (deleted as unknown as { id?: string }).id ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (idx !== -1) {
records.splice(idx, 1);
records = [...records];
}
const allIdx = allRecords.findIndex(r =>
r.id === (deleted as unknown as { id?: string }).id ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (allIdx !== -1) {
allRecords.splice(allIdx, 1);
allRecords = [...allRecords];
}
if (deleted.incomeExpense === '支出') {
backendTotalExpense = Math.max(0, backendTotalExpense - deleted.amount);
} else if (deleted.incomeExpense === '收入') {
backendTotalIncome = Math.max(0, backendTotalIncome - deleted.amount);
}
}
// 分类列表按数据中出现次数排序
let sortedCategories = $derived(() => {
const categoryCounts = new Map<string, number>();
@@ -289,7 +315,12 @@
<OverviewCards {totalStats} records={analysisRecords} />
<!-- 每日支出趋势图(按分类堆叠) - 使用全部数据 -->
<DailyTrendChart records={allAnalysisRecords} categories={sortedCategories()} onUpdate={handleBillUpdated} />
<DailyTrendChart
records={allAnalysisRecords}
categories={sortedCategories()}
onUpdate={handleBillUpdated}
onDelete={handleBillDeleted}
/>
<div class="grid gap-6 lg:grid-cols-2">
<!-- 分类支出排行 -->
@@ -307,7 +338,12 @@
</div>
<!-- Top 10 支出 -->
<TopExpenses records={topExpenses} categories={sortedCategories()} onUpdate={handleBillUpdated} />
<TopExpenses
records={topExpenses}
categories={sortedCategories()}
onUpdate={handleBillUpdated}
onDelete={handleBillDeleted}
/>
{:else}
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
<Card.Root>