From 642ea2d3ef30a8a5cf59f4592fd6505771032931 Mon Sep 17 00:00:00 2001 From: clz Date: Mon, 16 Feb 2026 22:28:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=88=86=E6=9E=90=E9=A1=B5=E9=9D=A2=E5=88=A0=E9=99=A4=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将删除接口从 DELETE /api/bills/:id 改为 POST /api/bills/:id/delete 以兼容 SvelteKit 代理 - 分析页面组件 (TopExpenses/BillRecordsTable/DailyTrendChart) 支持删除并同步更新统计数据 - Review 接口改为直接查询 MongoDB 而非读取文件 - 软删除时记录 updated_at 时间戳 - 添加 .dockerignore 文件优化构建 - 完善 AGENTS.md 文档 --- AGENTS.md | 184 ++++++++---------- analyzer/.dockerignore | 13 ++ server/.dockerignore | 25 +++ server/handler/delete_bill.go | 2 +- server/handler/review.go | 60 +++--- server/repository/mongo/repository.go | 7 +- server/router/router.go | 2 +- web/.dockerignore | 5 + web/src/lib/api.ts | 15 +- .../analysis/BillRecordsTable.svelte | 24 ++- .../analysis/DailyTrendChart.svelte | 26 ++- .../components/analysis/TopExpenses.svelte | 25 ++- web/src/routes/analysis/+page.svelte | 40 +++- 13 files changed, 277 insertions(+), 151 deletions(-) create mode 100644 analyzer/.dockerignore create mode 100644 server/.dockerignore create mode 100644 web/.dockerignore diff --git a/AGENTS.md b/AGENTS.md index f8558dc..cdcb6d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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; -} -``` +### 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 + } + ``` -**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"}) - 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 `. + - 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. diff --git a/analyzer/.dockerignore b/analyzer/.dockerignore new file mode 100644 index 0000000..bdd611e --- /dev/null +++ b/analyzer/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*$py.class +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.git +.gitignore +.DS_Store diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..d79737f --- /dev/null +++ b/server/.dockerignore @@ -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 diff --git a/server/handler/delete_bill.go b/server/handler/delete_bill.go index b9fe0be..474ad6b 100644 --- a/server/handler/delete_bill.go +++ b/server/handler/delete_bill.go @@ -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 == "" { diff --git a/server/handler/review.go b/server/handler/review.go index fb1b4a7..73227bc 100644 --- a/server/handler/review.go +++ b/server/handler/review.go @@ -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{ diff --git a/server/repository/mongo/repository.go b/server/repository/mongo/repository.go index ed14777..397128e 100644 --- a/server/repository/mongo/repository.go +++ b/server/repository/mongo/repository.go @@ -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) diff --git a/server/router/router.go b/server/router/router.go index da9b3a3..49b9f11 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -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) diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..9ec8dca --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +.DS_Store +.svelte-kit +build diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f8665cc..ecb88ca 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -128,17 +128,6 @@ export async function uploadBill( return response.json(); } -// 获取复核记录 -export async function getReviewRecords(fileName: string): Promise { - 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 { const response = await apiFetch(`${API_BASE}/api/monthly-stats`); @@ -403,8 +392,8 @@ export interface DeleteBillResponse { // 删除账单(软删除) export async function deleteBill(id: string): Promise { - 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) { diff --git a/web/src/lib/components/analysis/BillRecordsTable.svelte b/web/src/lib/components/analysis/BillRecordsTable.svelte index 5976f9d..7d073c1 100644 --- a/web/src/lib/components/analysis/BillRecordsTable.svelte +++ b/web/src/lib/components/analysis/BillRecordsTable.svelte @@ -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} /> diff --git a/web/src/lib/components/analysis/DailyTrendChart.svelte b/web/src/lib/components/analysis/DailyTrendChart.svelte index db5d95b..2b33691 100644 --- a/web/src/lib/components/analysis/DailyTrendChart.svelte +++ b/web/src/lib/components/analysis/DailyTrendChart.svelte @@ -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 数组 @@ -46,6 +47,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); @@ -923,6 +946,7 @@ pageSize={8} {categories} onUpdate={handleRecordUpdated} + onDelete={handleRecordDeleted} /> {:else} diff --git a/web/src/lib/components/analysis/TopExpenses.svelte b/web/src/lib/components/analysis/TopExpenses.svelte index e31895f..7faf73e 100644 --- a/web/src/lib/components/analysis/TopExpenses.svelte +++ b/web/src/lib/components/analysis/TopExpenses.svelte @@ -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(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); + } @@ -80,6 +101,8 @@ viewDescription="查看这笔支出的完整信息" editDescription="修改这笔支出的信息" onUpdate={handleRecordUpdated} + onDelete={handleRecordDeleted} + allowDelete={true} > {#snippet titleExtra({ isEditing })} {#if selectedRank <= 3 && !isEditing} diff --git a/web/src/routes/analysis/+page.svelte b/web/src/routes/analysis/+page.svelte index da4ca04..fe3a8ab 100644 --- a/web/src/routes/analysis/+page.svelte +++ b/web/src/routes/analysis/+page.svelte @@ -126,6 +126,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(() => { @@ -289,7 +315,12 @@ - +
@@ -307,7 +338,12 @@
- + {:else}