From a97a8d6a20a785e0c40bd40875e3d54d7591e538 Mon Sep 17 00:00:00 2001 From: CHE LIANG ZHAO Date: Fri, 23 Jan 2026 13:46:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81ZIP=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=E5=8C=85=E4=B8=8A=E4=BC=A0=EF=BC=88=E5=90=AB=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 254 ++++++++++++++++++ CHANGELOG.md | 31 +-- analyzer/cleaners/alipay.py | 26 +- analyzer/converter.py | 188 +++++++++++++ analyzer/requirements.txt | 1 + analyzer/server.py | 48 +++- .../微信支付账单(测试数据密码123456).zip | Bin 0 -> 7417 bytes .../支付宝交易明细(测试数据密码123456).zip | Bin 0 -> 9625 bytes server/adapter/adapter.go | 11 + server/adapter/http/cleaner.go | 82 ++++++ server/adapter/python/cleaner.go | 6 + server/go.mod | 5 +- server/go.sum | 2 + server/handler/upload.go | 155 ++++++++--- server/model/request.go | 13 +- server/service/archive.go | 159 +++++++++++ server/service/bill.go | 3 + server/service/cleaner.go | 7 + server/service/extractor.go | 1 + web/package.json | 2 +- web/src/lib/api.ts | 5 +- web/src/routes/+page.svelte | 46 +++- 22 files changed, 973 insertions(+), 72 deletions(-) create mode 100644 AGENTS.md create mode 100644 analyzer/converter.py create mode 100644 mock_data/微信支付账单(测试数据密码123456).zip create mode 100644 mock_data/支付宝交易明细(测试数据密码123456).zip create mode 100644 server/service/archive.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ca7f33 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,254 @@ +# AGENTS.md - AI Coding Agent Guidelines + +This document provides guidelines for AI coding agents working on the BillAI project. + +## Project Overview + +BillAI is a microservices-based personal bill analysis system supporting WeChat and Alipay bill parsing, intelligent categorization, and visualization. + +**Architecture:** +- `web/` - Frontend (SvelteKit 5 + TailwindCSS 4.x + TypeScript) +- `server/` - Backend API (Go 1.21 + Gin + MongoDB) +- `analyzer/` - Python analysis service (Python 3.12 + FastAPI) + +## Build, Lint, and Test Commands + +### Frontend (web/) + +```bash +# Development +npm run dev # Start dev server (Vite) + +# Build +npm run build # Production build +npm run preview # Preview production build + +# Type checking +npm run check # svelte-check with TypeScript +npm run check:watch # Watch mode + +# Linting and formatting +npm run lint # Prettier check + ESLint +npm run format # Format with Prettier + +# Testing +npm run test # Run all tests once +npm run test:unit # Run tests in watch mode +npx vitest run src/demo.spec.ts # Run single test file +npx vitest run -t "sum test" # Run tests matching name +npx vitest run src/routes/page.svelte.spec.ts # Run component test +``` + +### Backend (server/) + +```bash +# Run +go run . # Start development server + +# Build +go build . # Build binary + +# Dependencies +go mod download # Install dependencies +go mod tidy # Clean up dependencies + +# Testing (if tests exist) +go test ./... # Run all tests +go test ./handler/... # Run tests in specific package +go test -run TestName # Run single test by name +``` + +### Analyzer (analyzer/) + +```bash +# Setup +python -m venv venv +pip install -r requirements.txt + +# Run +python server.py # Start FastAPI server + +# Testing (if tests exist) +pytest # Run all tests +pytest test_file.py # Run single test file +pytest -k "test_name" # Run tests matching name +``` + +### Docker + +```bash +docker-compose up -d --build # Start all services +docker-compose ps # Check service status +docker-compose down # Stop all services +docker-compose logs -f web # Follow logs for specific service +``` + +## Code Style Guidelines + +### TypeScript/Svelte (Frontend) + +**Formatting (Prettier):** +- Use tabs for indentation +- Single quotes for strings +- No trailing commas +- Print width: 100 characters + +**Imports:** +- Use `$lib/` alias for imports from `src/lib/` +- Use `$app/` for SvelteKit internals +- Group imports: external packages, then internal modules + +```typescript +import { browser } from '$app/environment'; +import { auth } from '$lib/stores/auth'; +import type { UIBill } from '$lib/models/bill'; +``` + +**Types:** +- Define interfaces for API responses and requests +- Use `type` for unions and simple type aliases +- Export types from dedicated files in `$lib/types/` or alongside models + +```typescript +export interface UploadResponse { + result: boolean; + message: string; + data?: UploadData; +} +``` + +**Naming Conventions:** +- PascalCase: Components, interfaces, types +- camelCase: Functions, variables, properties +- Use descriptive names: `fetchBills`, `UIBill`, `checkHealth` + +**Error Handling:** +- Wrap API calls in try/catch +- Throw `Error` with HTTP status for API failures +- Handle 401 responses with logout redirect + +```typescript +if (!response.ok) { + throw new Error(`HTTP ${response.status}`); +} +``` + +### Go (Backend) + +**Project Structure:** +- `handler/` - HTTP request handlers +- `service/` - Business logic +- `repository/` - Data access layer +- `model/` - Data structures +- `adapter/` - External service integrations +- `config/` - Configuration management +- `middleware/` - Auth and other middleware + +**Naming Conventions:** +- PascalCase: Exported types, functions, constants +- camelCase: Unexported functions, variables +- Use descriptive names: `UpdateBillRequest`, `parseBillTime` + +**Error Handling:** +- Define sentinel errors in `repository/errors.go` +- Return errors up the call stack +- Use structured JSON responses for HTTP errors + +```go +if err == repository.ErrNotFound { + c.JSON(http.StatusNotFound, Response{Result: false, Message: "not found"}) + return +} +``` + +**JSON Tags:** +- Use snake_case for JSON field names +- Use `omitempty` for optional fields +- Match frontend API expectations + +```go +type UpdateBillRequest struct { + Category *string `json:"category,omitempty"` + Amount *float64 `json:"amount,omitempty"` +} +``` + +**Response Format:** +- All API responses use consistent structure: + +```go +type Response struct { + Result bool `json:"result"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` +} +``` + +### Python (Analyzer) + +**Style:** +- Follow PEP 8 +- Use type hints for function signatures +- Use Pydantic models for request/response validation + +```python +def do_clean( + input_path: str, + output_path: str, + bill_type: str = "auto" +) -> tuple[bool, str, str]: +``` + +**Error Handling:** +- Raise `HTTPException` for API errors +- Use try/except for file operations +- Return structured responses + +```python +if not success: + raise HTTPException(status_code=400, detail=message) +``` + +## Testing Guidelines + +**Frontend Tests:** +- Use Vitest with Playwright for browser testing +- Component tests: `*.svelte.spec.ts` +- Unit tests: `*.spec.ts` +- Tests require assertions: `expect.assertions()` or explicit expects + +```typescript +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +describe('/+page.svelte', () => { + it('should render h1', async () => { + render(Page); + await expect.element(page.getByRole('heading')).toBeInTheDocument(); + }); +}); +``` + +## Important Patterns + +**API Communication:** +- Frontend proxies API calls through SvelteKit to avoid CORS +- Backend uses Gin framework with JSON responses +- Analyzer communicates via HTTP (preferred) or subprocess + +**Data Flow:** +- Frontend (SvelteKit) -> Backend (Go/Gin) -> MongoDB +- Backend -> Analyzer (Python/FastAPI) for bill parsing + +**Authentication:** +- JWT tokens stored in frontend auth store +- Bearer token sent in Authorization header +- 401 responses trigger logout and redirect + +## File Locations + +- API types: `web/src/lib/api.ts` +- UI models: `web/src/lib/models/` +- Go handlers: `server/handler/` +- Go models: `server/model/` +- Python API: `analyzer/server.py` diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e186e..c23f4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,27 +5,22 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 -## [1.0.9] - 2026-01-19 - -### 移除 -- **移除 Webhook 自动部署功能** - 删除 webhook 服务及相关文件 - - 删除 `webhook/` 目录(Dockerfile、main.go、go.mod、README.md) - - 删除 `deploy.sh` 部署脚本 - - 删除 `WEBHOOK_SETUP.md` 配置文档 - - 移除 docker-compose.yaml 中的 webhook 服务配置 - -## [1.0.8] - 2026-01-18 - -### 重构 -- **前端账单模型统一为 UIBill** - 分析链路与详情弹窗只使用一套 UI 模型(camelCase + amount:number),移除 BillRecord 混用带来的字段/类型转换散落 - - 分析页、统计服务与各分析组件统一使用 `UIBill[]` - - CSV 解析(下载账单内容)直接输出 `UIBill[]` +## [1.1.0] - 2026-01-23 ### 新增 -- **账单详情弹窗抽象组件** - 新增 `BillDetailDrawer`,复用单笔账单的查看/编辑 UI 结构 +- **ZIP 压缩包上传** - 支持上传加密的 ZIP 压缩包(微信/支付宝导出的原始格式) + - 支持 AES 加密的 ZIP 文件,需输入解压密码 + - 自动将 xlsx 格式转换为 csv + - 自动将 GBK 编码转换为 UTF-8 + - 前端添加密码输入框 -### 优化 -- **前端检查更干净** - 修复图表容器的派生值捕获告警,并为趋势图增加键盘可访问性,`npm run check` 达到 0 warnings +### 修复 +- **支付宝扩展格式解析** - 修复从 ZIP 解压的支付宝账单(含 24 行元数据头)无法解析的问题 +- **CSV 字段数不一致** - 修复支付宝 CSV 文件字段数不一致导致解析失败的问题 +- **中文文件名乱码** - 修复 ZIP 内 GBK 编码的中文文件名解压后乱码的问题 + +### 其他 +- 添加 `AGENTS.md` 项目开发指南文档 ## [1.0.7] - 2026-01-16 diff --git a/analyzer/cleaners/alipay.py b/analyzer/cleaners/alipay.py index 95c3b4f..fab7eef 100644 --- a/analyzer/cleaners/alipay.py +++ b/analyzer/cleaners/alipay.py @@ -18,11 +18,31 @@ class AlipayCleaner(BaseCleaner): """执行清理""" self.print_header() - # 读取数据 + # 读取数据,跳过支付宝导出文件的头部信息 with open(self.input_file, "r", encoding="utf-8") as f: reader = csv.reader(f) - header = next(reader) - rows = list(reader) + header = None + rows = [] + + for row in reader: + # 跳过空行 + if not row or not row[0].strip(): + continue + + # 查找实际的CSV头部行(包含"交易时间"和"交易分类") + if header is None: + if len(row) >= 2 and "交易时间" in row[0] and "交易分类" in row[1]: + header = row + continue + # 跳过头部信息行 + continue + + # 收集数据行 + rows.append(row) + + # 确保找到了有效的头部 + if header is None: + raise ValueError("无法找到有效的支付宝账单表头(需包含'交易时间'和'交易分类'列)") self.stats["original_count"] = len(rows) print(f"原始数据行数: {len(rows)}") diff --git a/analyzer/converter.py b/analyzer/converter.py new file mode 100644 index 0000000..de8d608 --- /dev/null +++ b/analyzer/converter.py @@ -0,0 +1,188 @@ +""" +账单文件格式转换模块 + +支持: +- xlsx -> csv 转换 +- GBK/GB2312 -> UTF-8 编码转换 +- 账单类型自动检测 +""" +import os +import csv +import tempfile +from pathlib import Path +from typing import Optional, Tuple + +# 尝试导入 openpyxl,用于读取 xlsx 文件 +try: + from openpyxl import load_workbook + HAS_OPENPYXL = True +except ImportError: + HAS_OPENPYXL = False + + +def detect_encoding(filepath: str) -> str: + """ + 检测文件编码 + + Returns: + 'utf-8', 'gbk', 或 'utf-8-sig' + """ + # 尝试读取前几行来检测编码 + encodings = ['utf-8', 'utf-8-sig', 'gbk', 'gb2312', 'gb18030'] + + for encoding in encodings: + try: + with open(filepath, 'r', encoding=encoding) as f: + # 尝试读取前 10 行 + for _ in range(10): + f.readline() + return encoding + except (UnicodeDecodeError, UnicodeError): + continue + + # 默认使用 gbk + return 'gbk' + + +def detect_bill_type_from_content(content: str, filename: str = "") -> str: + """ + 从内容和文件名检测账单类型 + + Returns: + 'alipay', 'wechat', 或 '' + """ + # 从文件名检测 + filename_lower = filename.lower() + if '支付宝' in filename or 'alipay' in filename_lower: + return 'alipay' + if '微信' in filename or 'wechat' in filename_lower: + return 'wechat' + + # 从内容检测 + # 支付宝特征: 有 "交易分类" 和 "对方账号" 列 + if '交易分类' in content and '对方账号' in content: + return 'alipay' + + # 微信特征: 有 "交易类型" 和 "金额(元)" 列 + if '交易类型' in content and '金额(元)' in content: + return 'wechat' + + return '' + + +def convert_xlsx_to_csv(xlsx_path: str, csv_path: str) -> Tuple[bool, str]: + """ + 将 xlsx 文件转换为 csv 文件 + + Returns: + (success, message) + """ + if not HAS_OPENPYXL: + return False, "缺少 openpyxl 库,无法读取 xlsx 文件。请运行: pip install openpyxl" + + try: + wb = load_workbook(xlsx_path, read_only=True, data_only=True) + ws = wb.active + + with open(csv_path, 'w', encoding='utf-8', newline='') as f: + writer = csv.writer(f) + for row in ws.iter_rows(values_only=True): + # 跳过全空行 + if all(cell is None for cell in row): + continue + # 将 None 转换为空字符串 + writer.writerow(['' if cell is None else str(cell) for cell in row]) + + wb.close() + return True, "xlsx 转换成功" + + except Exception as e: + return False, f"xlsx 转换失败: {str(e)}" + + +def convert_csv_encoding(input_path: str, output_path: str, source_encoding: str = 'auto') -> Tuple[bool, str]: + """ + 将 csv 文件从 GBK/其他编码转换为 UTF-8 + + Returns: + (success, message) + """ + if source_encoding == 'auto': + source_encoding = detect_encoding(input_path) + + # 如果已经是 UTF-8,直接复制 + if source_encoding in ('utf-8', 'utf-8-sig'): + if input_path != output_path: + import shutil + shutil.copy(input_path, output_path) + return True, "文件已是 UTF-8 编码" + + try: + with open(input_path, 'r', encoding=source_encoding) as f_in: + content = f_in.read() + + with open(output_path, 'w', encoding='utf-8', newline='') as f_out: + f_out.write(content) + + return True, f"编码转换成功: {source_encoding} -> utf-8" + + except Exception as e: + return False, f"编码转换失败: {str(e)}" + + +def convert_bill_file(input_path: str, output_path: Optional[str] = None) -> Tuple[bool, str, str, str]: + """ + 转换账单文件为标准 CSV 格式(UTF-8 编码) + + 支持: + - xlsx -> csv 转换 + - GBK/GB2312 -> UTF-8 编码转换 + + Args: + input_path: 输入文件路径 + output_path: 输出文件路径(可选,默认在同目录生成) + + Returns: + (success, bill_type, output_path, message) + """ + input_path = Path(input_path) + + if not input_path.exists(): + return False, '', '', f"文件不存在: {input_path}" + + # 确定输出路径 + if output_path is None: + # 生成临时文件 + suffix = '.csv' + fd, output_path = tempfile.mkstemp(suffix=suffix) + os.close(fd) + + ext = input_path.suffix.lower() + bill_type = '' + + if ext == '.xlsx': + # xlsx 转换 + success, message = convert_xlsx_to_csv(str(input_path), output_path) + if not success: + return False, '', '', message + + # 读取内容检测账单类型 + with open(output_path, 'r', encoding='utf-8') as f: + content = f.read(2000) # 只读取前 2000 字符用于检测 + bill_type = detect_bill_type_from_content(content, input_path.name) + + elif ext == '.csv': + # CSV 编码转换 + success, message = convert_csv_encoding(str(input_path), output_path) + if not success: + return False, '', '', message + + # 读取内容检测账单类型 + with open(output_path, 'r', encoding='utf-8') as f: + content = f.read(2000) + bill_type = detect_bill_type_from_content(content, input_path.name) + + else: + return False, '', '', f"不支持的文件格式: {ext}" + + return True, bill_type, output_path, "转换成功" diff --git a/analyzer/requirements.txt b/analyzer/requirements.txt index 3dde17e..eda27a3 100644 --- a/analyzer/requirements.txt +++ b/analyzer/requirements.txt @@ -2,3 +2,4 @@ pyyaml>=6.0 fastapi>=0.109.0 uvicorn[standard]>=0.27.0 python-multipart>=0.0.6 +openpyxl>=3.1.0 diff --git a/analyzer/server.py b/analyzer/server.py index 19cca9d..b5451ef 100644 --- a/analyzer/server.py +++ b/analyzer/server.py @@ -24,6 +24,7 @@ if sys.stdout.encoding != 'utf-8': from cleaners.base import compute_date_range_from_values from cleaners import AlipayCleaner, WechatCleaner from category import infer_category, get_all_categories, get_all_income_categories +from converter import convert_bill_file # 应用版本 APP_VERSION = "0.0.1" @@ -72,6 +73,14 @@ class HealthResponse(BaseModel): version: str +class ConvertResponse(BaseModel): + """文件转换响应""" + success: bool + bill_type: str + output_path: str + message: str + + # ============================================================================= # 辅助函数 # ============================================================================= @@ -85,7 +94,7 @@ def detect_bill_type(filepath: str) -> str | None: """ try: with open(filepath, "r", encoding="utf-8") as f: - for _ in range(20): + for _ in range(50): # 支付宝账单可能有较多的头部信息行 line = f.readline() if not line: break @@ -337,6 +346,43 @@ async def detect_bill_type_api(file: UploadFile = File(...)): os.unlink(tmp_path) +@app.post("/convert", response_model=ConvertResponse) +async def convert_bill_file_api(file: UploadFile = File(...)): + """ + 转换账单文件格式 + + 支持: + - xlsx -> csv 转换 + - GBK/GB2312 -> UTF-8 编码转换 + + 返回转换后的文件路径和检测到的账单类型 + """ + # 保存上传的文件到临时位置 + suffix = Path(file.filename).suffix or ".csv" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + shutil.copyfileobj(file.file, tmp) + input_path = tmp.name + + try: + # 调用转换函数 + success, bill_type, output_path, message = convert_bill_file(input_path) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return ConvertResponse( + success=True, + bill_type=bill_type, + output_path=output_path, + message=message + ) + + finally: + # 清理输入临时文件(转换后的输出文件由调用方负责清理) + if os.path.exists(input_path): + os.unlink(input_path) + + # ============================================================================= # 启动入口 # ============================================================================= diff --git a/mock_data/微信支付账单(测试数据密码123456).zip b/mock_data/微信支付账单(测试数据密码123456).zip new file mode 100644 index 0000000000000000000000000000000000000000..9274c2cf6fa95686b9168ac5a08a748d2c199dd2 GIT binary patch literal 7417 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 L8Sx+B1OxkjJ+WBj literal 0 HcmV?d00001 diff --git a/mock_data/支付宝交易明细(测试数据密码123456).zip b/mock_data/支付宝交易明细(测试数据密码123456).zip new file mode 100644 index 0000000000000000000000000000000000000000..10f28454e897b89e1622e5050b053f33b24df4ea GIT binary patch literal 9625 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 literal 0 HcmV?d00001 diff --git a/server/adapter/adapter.go b/server/adapter/adapter.go index b061304..ca1649d 100644 --- a/server/adapter/adapter.go +++ b/server/adapter/adapter.go @@ -17,6 +17,12 @@ type CleanResult struct { Output string // 脚本输出信息 } +// ConvertResult 格式转换结果 +type ConvertResult struct { + OutputPath string // 转换后的文件路径 + BillType string // 检测到的账单类型: alipay/wechat +} + // Cleaner 账单清洗器接口 // 负责将原始账单数据清洗为标准格式 type Cleaner interface { @@ -25,4 +31,9 @@ type Cleaner interface { // outputPath: 输出文件路径 // opts: 清洗选项 Clean(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) + + // Convert 转换账单文件格式(xlsx -> csv,处理 GBK 编码等) + // inputPath: 输入文件路径 + // 返回: 转换后的文件路径, 检测到的账单类型, 错误 + Convert(inputPath string) (outputPath string, billType string, err error) } diff --git a/server/adapter/http/cleaner.go b/server/adapter/http/cleaner.go index da60cb1..dc96562 100644 --- a/server/adapter/http/cleaner.go +++ b/server/adapter/http/cleaner.go @@ -185,6 +185,88 @@ func (c *Cleaner) downloadFile(remotePath, localPath string) error { return nil } +// ConvertResponse 转换响应 +type ConvertResponse struct { + Success bool `json:"success"` + BillType string `json:"bill_type"` + Message string `json:"message"` + OutputPath string `json:"output_path,omitempty"` +} + +// Convert 转换账单文件格式(xlsx -> csv,处理 GBK 编码等) +func (c *Cleaner) Convert(inputPath string) (outputPath string, billType string, err error) { + // 打开输入文件 + file, err := os.Open(inputPath) + if err != nil { + return "", "", fmt.Errorf("打开文件失败: %w", err) + } + defer file.Close() + + // 创建 multipart form + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // 添加文件 + part, err := writer.CreateFormFile("file", filepath.Base(inputPath)) + if err != nil { + return "", "", fmt.Errorf("创建表单文件失败: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return "", "", fmt.Errorf("复制文件内容失败: %w", err) + } + writer.Close() + + // 发送转换请求 + fmt.Printf("🌐 调用转换服务: %s/convert\n", c.baseURL) + req, err := http.NewRequest("POST", c.baseURL+"/convert", &body) + if err != nil { + return "", "", fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("HTTP 请求失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("读取响应失败: %w", err) + } + + // 处理错误响应 + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(respBody, &errResp); err == nil { + return "", "", fmt.Errorf("转换失败: %s", errResp.Detail) + } + return "", "", fmt.Errorf("转换失败: HTTP %d - %s", resp.StatusCode, string(respBody)) + } + + // 解析成功响应 + var convertResp ConvertResponse + if err := json.Unmarshal(respBody, &convertResp); err != nil { + return "", "", fmt.Errorf("解析响应失败: %w", err) + } + + // 下载转换后的文件到本地(与输入文件同目录,但扩展名改为 .csv) + localOutputPath := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))] + ".csv" + fmt.Printf(" 下载转换后文件: %s -> %s\n", convertResp.OutputPath, localOutputPath) + if err := c.downloadFile(convertResp.OutputPath, localOutputPath); err != nil { + return "", "", fmt.Errorf("下载转换结果失败: %w", err) + } + + // 验证文件是否存在 + if _, err := os.Stat(localOutputPath); err != nil { + return "", "", fmt.Errorf("下载后文件不存在: %s", localOutputPath) + } + fmt.Printf(" 文件下载成功,已保存到: %s\n", localOutputPath) + + return localOutputPath, convertResp.BillType, nil +} + // HealthCheck 检查 Python 服务健康状态 func (c *Cleaner) HealthCheck() error { resp, err := c.httpClient.Get(c.baseURL + "/health") diff --git a/server/adapter/python/cleaner.go b/server/adapter/python/cleaner.go index d08b4d2..13dfe66 100644 --- a/server/adapter/python/cleaner.go +++ b/server/adapter/python/cleaner.go @@ -90,5 +90,11 @@ func detectBillTypeFromOutput(output string) string { return "" } +// Convert 转换账单文件格式(xlsx -> csv,处理 GBK 编码等) +// 子进程模式不支持此功能,请使用 HTTP 模式 +func (c *Cleaner) Convert(inputPath string) (outputPath string, billType string, err error) { + return "", "", fmt.Errorf("子进程模式不支持文件格式转换,请使用 HTTP 模式 (analyzer_mode: http)") +} + // 确保 Cleaner 实现了 adapter.Cleaner 接口 var _ adapter.Cleaner = (*Cleaner)(nil) diff --git a/server/go.mod b/server/go.mod index 0da7bb9..127c341 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,7 +4,10 @@ go 1.21 require ( github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 go.mongodb.org/mongo-driver v1.13.1 + golang.org/x/text v0.9.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -17,7 +20,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/snappy v0.0.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.13.6 // indirect @@ -39,6 +41,5 @@ require ( golang.org/x/net v0.10.0 // indirect golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect google.golang.org/protobuf v1.30.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index c229350..2ab1518 100644 --- a/server/go.sum +++ b/server/go.sum @@ -75,6 +75,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M= +github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/server/handler/upload.go b/server/handler/upload.go index ca33192..4f34e63 100644 --- a/server/handler/upload.go +++ b/server/handler/upload.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/gin-gonic/gin" @@ -18,6 +19,8 @@ import ( ) // Upload 处理账单上传和清理请求 +// 支持直接上传 CSV 文件,或上传 ZIP 压缩包(支持密码保护) +// ZIP 包内可以是 CSV 或 XLSX 格式的账单文件 func Upload(c *gin.Context) { // 1. 获取上传的文件 file, header, err := c.Request.FormFile("file") @@ -37,32 +40,12 @@ func Upload(c *gin.Context) { req.Format = "csv" } - // 验证 type 参数 - if req.Type == "" { - c.JSON(http.StatusBadRequest, model.UploadResponse{ - Result: false, - Message: "请指定账单类型 (type: alipay 或 wechat)", - }) - return - } - if req.Type != "alipay" && req.Type != "wechat" { - c.JSON(http.StatusBadRequest, model.UploadResponse{ - Result: false, - Message: "账单类型无效,仅支持 alipay 或 wechat", - }) - return - } - billType := req.Type - - // 3. 保存上传的文件(添加唯一ID避免覆盖) + // 3. 保存上传的文件 timestamp := time.Now().Format("20060102_150405") uniqueID := generateShortID() - // 获取文件扩展名和基础名 ext := filepath.Ext(header.Filename) baseName := header.Filename[:len(header.Filename)-len(ext)] - - // 文件名格式: 时间戳_唯一ID_原始文件名 inputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, uniqueID, baseName, ext) uploadDirAbs := config.ResolvePath(config.Global.UploadDir) inputPath := filepath.Join(uploadDirAbs, inputFileName) @@ -76,12 +59,117 @@ func Upload(c *gin.Context) { return } defer dst.Close() - io.Copy(dst, file) + if _, err := io.Copy(dst, file); err != nil { + c.JSON(http.StatusInternalServerError, model.UploadResponse{ + Result: false, + Message: "保存文件失败: " + err.Error(), + }) + return + } + dst.Close() // 关闭文件以便后续处理 - // 4. 对原始数据进行去重检查 + // 4. 处理文件:如果是 ZIP 则解压,否则直接处理 + var billFilePath string + var billType string + var extractedFiles []string + var needConvert bool // 是否需要格式转换(xlsx -> csv) + + if service.IsSupportedArchive(header.Filename) { + // 解压 ZIP 文件 + fmt.Printf("📦 检测到 ZIP 文件,开始解压...\n") + extractResult, err := service.ExtractZip(inputPath, uploadDirAbs, req.Password) + if err != nil { + c.JSON(http.StatusBadRequest, model.UploadResponse{ + Result: false, + Message: "解压失败: " + err.Error(), + }) + return + } + + billFilePath = extractResult.BillFile + extractedFiles = extractResult.ExtractedFiles + + // 使用从文件名检测到的账单类型(如果用户未指定) + if req.Type == "" && extractResult.BillType != "" { + billType = extractResult.BillType + } + + fmt.Printf(" 解压完成,账单文件: %s\n", filepath.Base(billFilePath)) + + // ZIP 中提取的文件需要格式转换(xlsx 需要转 csv,csv 可能需要编码转换) + needConvert = true + } else { + // 直接使用上传的文件 + billFilePath = inputPath + + // 检查是否为 xlsx 格式 + if strings.HasSuffix(strings.ToLower(header.Filename), ".xlsx") { + needConvert = true + } + } + + // 5. 如果需要格式/编码转换,调用 analyzer 服务 + if needConvert { + fmt.Printf("📊 调用分析服务进行格式/编码转换...\n") + convertedPath, detectedType, err := service.ConvertBillFile(billFilePath) + if err != nil { + // 清理临时文件 + service.CleanupExtractedFiles(extractedFiles) + c.JSON(http.StatusBadRequest, model.UploadResponse{ + Result: false, + Message: "文件转换失败: " + err.Error(), + }) + return + } + // 如果转换后的路径与原路径不同,删除原始文件 + if convertedPath != billFilePath { + os.Remove(billFilePath) + } + billFilePath = convertedPath + + // 使用检测到的账单类型 + if req.Type == "" && detectedType != "" { + billType = detectedType + } + fmt.Printf(" 转换完成: %s\n", filepath.Base(convertedPath)) + } + + // 6. 确定账单类型 + if req.Type != "" { + billType = req.Type + } + if billType == "" { + // 尝试从文件名检测 + fileName := strings.ToLower(filepath.Base(billFilePath)) + if strings.Contains(fileName, "支付宝") || strings.Contains(fileName, "alipay") { + billType = "alipay" + } else if strings.Contains(fileName, "微信") || strings.Contains(fileName, "wechat") { + billType = "wechat" + } + } + if billType == "" { + // 清理临时文件 + service.CleanupExtractedFiles(extractedFiles) + c.JSON(http.StatusBadRequest, model.UploadResponse{ + Result: false, + Message: "无法识别账单类型,请指定 type 参数 (alipay 或 wechat)", + }) + return + } + if billType != "alipay" && billType != "wechat" { + service.CleanupExtractedFiles(extractedFiles) + c.JSON(http.StatusBadRequest, model.UploadResponse{ + Result: false, + Message: "账单类型无效,仅支持 alipay 或 wechat", + }) + return + } + + // 7. 对原始数据进行去重检查 fmt.Printf("📋 开始去重检查...\n") - dedupResult, dedupErr := service.DeduplicateRawFile(inputPath, timestamp) + dedupResult, dedupErr := service.DeduplicateRawFile(billFilePath, timestamp) if dedupErr != nil { + service.CleanupExtractedFiles(extractedFiles) c.JSON(http.StatusInternalServerError, model.UploadResponse{ Result: false, Message: "去重检查失败: " + dedupErr.Error(), @@ -97,6 +185,7 @@ func Upload(c *gin.Context) { // 如果全部重复,返回提示 if dedupResult.NewCount == 0 { + service.CleanupExtractedFiles(extractedFiles) c.JSON(http.StatusOK, model.UploadResponse{ Result: true, Message: fmt.Sprintf("文件中的 %d 条记录全部已存在,无需重复导入", dedupResult.OriginalCount), @@ -113,7 +202,7 @@ func Upload(c *gin.Context) { // 使用去重后的文件路径进行后续处理 processFilePath := dedupResult.DedupFilePath - // 5. 构建输出文件路径:时间_type_编号 + // 8. 构建输出文件路径 outputExt := ".csv" if req.Format == "json" { outputExt = ".json" @@ -123,7 +212,7 @@ func Upload(c *gin.Context) { outputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, billType, fileSeq, outputExt) outputPath := filepath.Join(outputDirAbs, outputFileName) - // 6. 执行 Python 清洗脚本 + // 9. 执行 Python 清洗脚本 cleanOpts := &service.CleanOptions{ Year: req.Year, Month: req.Month, @@ -133,6 +222,7 @@ func Upload(c *gin.Context) { } _, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts) if cleanErr != nil { + service.CleanupExtractedFiles(extractedFiles) c.JSON(http.StatusInternalServerError, model.UploadResponse{ Result: false, Message: cleanErr.Error(), @@ -140,7 +230,7 @@ func Upload(c *gin.Context) { return } - // 7. 将去重后的原始数据存入 MongoDB(原始数据集合) + // 10. 将去重后的原始数据存入 MongoDB rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp) if rawErr != nil { fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr) @@ -148,7 +238,7 @@ func Upload(c *gin.Context) { fmt.Printf("✅ 已存储 %d 条原始账单记录到 MongoDB\n", rawCount) } - // 9. 将清洗后的数据存入 MongoDB(清洗后数据集合) + // 11. 将清洗后的数据存入 MongoDB cleanedCount, _, cleanedErr := service.SaveCleanedBillsFromFile(outputPath, req.Format, billType, header.Filename, timestamp) if cleanedErr != nil { fmt.Printf("⚠️ 存储清洗后数据到 MongoDB 失败: %v\n", cleanedErr) @@ -156,12 +246,13 @@ func Upload(c *gin.Context) { fmt.Printf("✅ 已存储 %d 条清洗后账单记录到 MongoDB\n", cleanedCount) } - // 10. 清理临时的去重文件(如果生成了的话) + // 12. 清理临时文件 if dedupResult.DedupFilePath != inputPath && dedupResult.DedupFilePath != "" { os.Remove(dedupResult.DedupFilePath) } + service.CleanupExtractedFiles(extractedFiles) - // 11. 返回成功响应 + // 13. 返回成功响应 message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount) if dedupResult.DuplicateCount > 0 { message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount) @@ -182,7 +273,6 @@ func Upload(c *gin.Context) { } // generateFileSequence 生成文件序号 -// 根据当前目录下同一时间戳和类型的文件数量生成序号 func generateFileSequence(dir, timestamp, billType, ext string) string { pattern := fmt.Sprintf("%s_%s_*%s", timestamp, billType, ext) matches, err := filepath.Glob(filepath.Join(dir, pattern)) @@ -194,9 +284,8 @@ func generateFileSequence(dir, timestamp, billType, ext string) string { // generateShortID 生成 6 位随机唯一标识符 func generateShortID() string { - bytes := make([]byte, 3) // 3 字节 = 6 个十六进制字符 + bytes := make([]byte, 3) if _, err := rand.Read(bytes); err != nil { - // 如果随机数生成失败,使用时间纳秒作为备选 return fmt.Sprintf("%06x", time.Now().UnixNano()%0xFFFFFF) } return hex.EncodeToString(bytes) diff --git a/server/model/request.go b/server/model/request.go index 70dccd2..b501504 100644 --- a/server/model/request.go +++ b/server/model/request.go @@ -2,10 +2,11 @@ package model // UploadRequest 上传请求参数 type UploadRequest struct { - Type string `form:"type"` // 账单类型: alipay/wechat(必填) - Year string `form:"year"` // 年份筛选 - Month string `form:"month"` // 月份筛选 - Start string `form:"start"` // 起始日期 - End string `form:"end"` // 结束日期 - Format string `form:"format"` // 输出格式: csv/json + Type string `form:"type"` // 账单类型: alipay/wechat(可选,会自动检测) + Password string `form:"password"` // ZIP 文件密码(可选) + Year string `form:"year"` // 年份筛选 + Month string `form:"month"` // 月份筛选 + Start string `form:"start"` // 起始日期 + End string `form:"end"` // 结束日期 + Format string `form:"format"` // 输出格式: csv/json } diff --git a/server/service/archive.go b/server/service/archive.go new file mode 100644 index 0000000..3112ff9 --- /dev/null +++ b/server/service/archive.go @@ -0,0 +1,159 @@ +package service + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/yeka/zip" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +// ExtractResult 解压结果 +type ExtractResult struct { + ExtractedFiles []string // 解压出的文件路径 + BillFile string // 账单文件路径(csv 或 xlsx) + BillType string // 检测到的账单类型 +} + +// ExtractZip 解压 ZIP 文件,支持密码 +// 返回解压后的账单文件路径 +func ExtractZip(zipPath, destDir, password string) (*ExtractResult, error) { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return nil, fmt.Errorf("无法打开 ZIP 文件: %w", err) + } + defer reader.Close() + + result := &ExtractResult{ + ExtractedFiles: make([]string, 0), + } + + timestamp := time.Now().Format("20060102_150405") + + for _, file := range reader.File { + // 处理文件名编码(可能是 GBK) + fileName := decodeFileName(file.Name) + + // 安全检查:防止路径遍历 + if strings.Contains(fileName, "..") { + continue + } + + // 获取文件扩展名 + ext := strings.ToLower(filepath.Ext(fileName)) + + // 生成安全的目标文件名(避免编码问题) + // 使用时间戳+序号+扩展名的格式 + safeFileName := fmt.Sprintf("extracted_%s_%d%s", timestamp, len(result.ExtractedFiles), ext) + destPath := filepath.Join(destDir, safeFileName) + + if file.FileInfo().IsDir() { + os.MkdirAll(destPath, 0755) + continue + } + + // 确保目录存在 + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return nil, fmt.Errorf("创建目录失败: %w", err) + } + + // 设置密码(如果有) + if file.IsEncrypted() { + if password == "" { + return nil, fmt.Errorf("ZIP 文件已加密,请提供密码") + } + file.SetPassword(password) + } + + // 打开文件 + rc, err := file.Open() + if err != nil { + if file.IsEncrypted() { + return nil, fmt.Errorf("密码错误或无法解密文件") + } + return nil, fmt.Errorf("无法读取文件 %s: %w", fileName, err) + } + + // 写入文件 + destFile, err := os.Create(destPath) + if err != nil { + rc.Close() + return nil, fmt.Errorf("创建文件失败: %w", err) + } + + _, err = io.Copy(destFile, rc) + rc.Close() + destFile.Close() + + if err != nil { + return nil, fmt.Errorf("写入文件失败: %w", err) + } + + result.ExtractedFiles = append(result.ExtractedFiles, destPath) + + // 检测账单文件 + if ext == ".csv" || ext == ".xlsx" { + result.BillFile = destPath + + // 检测账单类型(从原始文件名检测) + if strings.Contains(fileName, "支付宝") || strings.Contains(strings.ToLower(fileName), "alipay") { + result.BillType = "alipay" + } else if strings.Contains(fileName, "微信") || strings.Contains(strings.ToLower(fileName), "wechat") { + result.BillType = "wechat" + } + } + } + + if result.BillFile == "" { + return nil, fmt.Errorf("ZIP 文件中未找到账单文件(.csv 或 .xlsx)") + } + + return result, nil +} + +// decodeFileName 尝试将 GBK 编码的文件名转换为 UTF-8 +func decodeFileName(name string) string { + // 如果文件名只包含 ASCII 字符,直接返回 + isAscii := true + for i := 0; i < len(name); i++ { + if name[i] > 127 { + isAscii = false + break + } + } + if isAscii { + return name + } + + // 尝试 GBK 解码 + // Windows 上创建的 ZIP 文件通常使用 GBK 编码中文文件名 + decoded, _, err := transform.String(simplifiedchinese.GBK.NewDecoder(), name) + if err == nil && len(decoded) > 0 { + return decoded + } + return name +} + +// IsSupportedArchive 检查文件是否为支持的压缩格式 +func IsSupportedArchive(filename string) bool { + lower := strings.ToLower(filename) + return strings.HasSuffix(lower, ".zip") +} + +// IsBillFile 检查文件是否为账单文件 +func IsBillFile(filename string) bool { + lower := strings.ToLower(filename) + return strings.HasSuffix(lower, ".csv") || strings.HasSuffix(lower, ".xlsx") +} + +// CleanupExtractedFiles 清理解压的临时文件 +func CleanupExtractedFiles(files []string) { + for _, f := range files { + os.Remove(f) + } +} diff --git a/server/service/bill.go b/server/service/bill.go index 192a4c4..515ab31 100644 --- a/server/service/bill.go +++ b/server/service/bill.go @@ -47,6 +47,7 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error defer file.Close() reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 // 允许变长记录 rows, err := reader.ReadAll() if err != nil { return nil, fmt.Errorf("读取 CSV 失败: %w", err) @@ -183,6 +184,7 @@ func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (i defer file.Close() reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 // 允许变长记录 rows, err := reader.ReadAll() if err != nil { return 0, fmt.Errorf("读取 CSV 失败: %w", err) @@ -249,6 +251,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) defer file.Close() reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 // 允许变长记录 rows, err := reader.ReadAll() if err != nil { return 0, 0, fmt.Errorf("读取 CSV 失败: %w", err) diff --git a/server/service/cleaner.go b/server/service/cleaner.go index 691e039..e0b6c1b 100644 --- a/server/service/cleaner.go +++ b/server/service/cleaner.go @@ -20,6 +20,13 @@ func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanRes return cleaner.Clean(inputPath, outputPath, opts) } +// ConvertBillFile 转换账单文件格式(xlsx -> csv,处理编码) +// 返回转换后的文件路径和检测到的账单类型 +func ConvertBillFile(inputPath string) (outputPath string, billType string, err error) { + cleaner := adapter.GetCleaner() + return cleaner.Convert(inputPath) +} + // DetectBillTypeFromOutput 从脚本输出中检测账单类型 // 保留此函数以兼容其他调用 func DetectBillTypeFromOutput(output string) string { diff --git a/server/service/extractor.go b/server/service/extractor.go index a37ddeb..e7ae37e 100644 --- a/server/service/extractor.go +++ b/server/service/extractor.go @@ -27,6 +27,7 @@ func extractFromCSV(filePath string) []model.ReviewRecord { defer file.Close() reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 // 允许变长记录 rows, err := reader.ReadAll() if err != nil || len(rows) < 2 { return records diff --git a/web/package.json b/web/package.json index 8238068..16379fb 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "1.0.9", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite dev", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index dd7112a..aaad449 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -100,7 +100,7 @@ export interface MonthlyStatsResponse { export async function uploadBill( file: File, type: BillType, - options?: { year?: number; month?: number } + options?: { year?: number; month?: number; password?: string } ): Promise { const formData = new FormData(); formData.append('file', file); @@ -112,6 +112,9 @@ export async function uploadBill( if (options?.month) { formData.append('month', options.month.toString()); } + if (options?.password) { + formData.append('password', options.password); + } const response = await apiFetch(`${API_BASE}/api/upload`, { method: 'POST', diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 4451522..f49c25c 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -23,6 +23,8 @@ let isUploading = $state(false); let uploadResult: UploadResponse | null = $state(null); let errorMessage = $state(''); + let zipPassword = $state(''); + let isZipFile = $state(false); type StatTrend = 'up' | 'down'; interface StatCard { @@ -186,16 +188,27 @@ } function selectFile(file: File) { - if (!file.name.endsWith('.csv')) { - errorMessage = '请选择 CSV 格式的账单文件'; + const fileName = file.name.toLowerCase(); + const isZip = fileName.endsWith('.zip'); + const isCsv = fileName.endsWith('.csv'); + const isXlsx = fileName.endsWith('.xlsx'); + + if (!isCsv && !isZip && !isXlsx) { + errorMessage = '请选择 CSV、XLSX 或 ZIP 格式的账单文件'; return; } + selectedFile = file; + isZipFile = isZip; errorMessage = ''; uploadResult = null; + // 如果不是 ZIP 文件,清空密码 + if (!isZip) { + zipPassword = ''; + } + // 根据文件名自动识别账单类型 - const fileName = file.name.toLowerCase(); if (fileName.includes('支付宝') || fileName.includes('alipay')) { selectedType = 'alipay'; } else if (fileName.includes('微信') || fileName.includes('wechat')) { @@ -207,6 +220,8 @@ selectedFile = null; uploadResult = null; errorMessage = ''; + zipPassword = ''; + isZipFile = false; } async function handleUpload() { @@ -216,7 +231,11 @@ errorMessage = ''; try { - const result = await uploadBill(selectedFile, selectedType); + const options: { year?: number; month?: number; password?: string } = {}; + if (isZipFile && zipPassword) { + options.password = zipPassword; + } + const result = await uploadBill(selectedFile, selectedType, options); if (result.result) { uploadResult = result; } else { @@ -278,7 +297,7 @@
上传账单 - 支持支付宝、微信账单 CSV 文件 + 支持支付宝、微信账单 CSV、XLSX 或 ZIP 文件