From 087ae027cc4c49c1e23f1b5e2b0d9e93599a3992 Mon Sep 17 00:00:00 2001 From: cheliangzhao Date: Sat, 10 Jan 2026 01:15:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=B9=B6=E5=A2=9E=E5=BC=BA=E5=88=86=E6=9E=90?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增项目文档和 Docker 配置 - 添加 README.md 和 TODO.md 项目文档 - 为各服务添加 Dockerfile 和 docker-compose 配置 - 重构后端架构 - 新增 adapter 层(HTTP/Python 适配器) - 新增 repository 层(数据访问抽象) - 新增 router 模块统一管理路由 - 新增账单处理 handler - 扩展前端 UI 组件库 - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件 - 集成 shadcn-svelte 组件库 - 增强分析页面功能 - 添加时间范围筛选器(支持本月默认值) - 修复 DateRangePicker 默认值显示问题 - 优化数据获取和展示逻辑 - 完善分析器服务 - 新增 FastAPI 服务接口 - 改进账单清理器实现 --- README.md | 265 +++++++++ TODO.md | 101 ++++ analyzer/Dockerfile | 25 + analyzer/cleaners/alipay.py | 8 +- analyzer/cleaners/base.py | 52 ++ analyzer/cleaners/wechat.py | 8 +- analyzer/requirements.txt | 4 +- analyzer/server.py | 348 ++++++++++++ docker-compose.yaml | 77 +++ server/Dockerfile | 49 ++ server/adapter/adapter.go | 28 + server/adapter/global.go | 15 + server/adapter/http/cleaner.go | 204 +++++++ server/adapter/python/cleaner.go | 94 +++ server/config.yaml | 11 +- server/config/config.go | 27 +- server/handler/bills.go | 165 ++++++ server/handler/upload.go | 47 +- server/main.go | 105 +++- server/model/request.go | 2 +- server/repository/global.go | 14 + server/repository/repository.go | 44 ++ server/router/router.go | 53 ++ server/service/bill.go | 14 +- server/service/cleaner.go | 92 +-- web/.npmrc | 1 + web/Dockerfile | 56 ++ web/package.json | 2 +- web/src/lib/api.ts | 115 +++- .../analysis/BillRecordsTable.svelte | 30 +- .../analysis/CategoryRanking.svelte | 29 +- .../analysis/DailyTrendChart.svelte | 40 +- .../components/analysis/TopExpenses.svelte | 30 +- .../ui/calendar/calendar-caption.svelte | 76 +++ .../ui/calendar/calendar-cell.svelte | 19 + .../ui/calendar/calendar-day.svelte | 35 ++ .../ui/calendar/calendar-grid-body.svelte | 12 + .../ui/calendar/calendar-grid-head.svelte | 12 + .../ui/calendar/calendar-grid-row.svelte | 12 + .../ui/calendar/calendar-grid.svelte | 16 + .../ui/calendar/calendar-head-cell.svelte | 19 + .../ui/calendar/calendar-header.svelte | 19 + .../ui/calendar/calendar-heading.svelte | 16 + .../ui/calendar/calendar-month-select.svelte | 44 ++ .../ui/calendar/calendar-month.svelte | 15 + .../ui/calendar/calendar-months.svelte | 19 + .../ui/calendar/calendar-nav.svelte | 19 + .../ui/calendar/calendar-next-button.svelte | 31 + .../ui/calendar/calendar-prev-button.svelte | 31 + .../ui/calendar/calendar-year-select.svelte | 43 ++ .../components/ui/calendar/calendar.svelte | 115 ++++ web/src/lib/components/ui/calendar/index.ts | 40 ++ .../date-range-picker.svelte | 83 +++ .../components/ui/date-range-picker/index.ts | 3 + .../components/ui/drawer/drawer-close.svelte | 25 + .../ui/drawer/drawer-content.svelte | 33 ++ .../ui/drawer/drawer-description.svelte | 25 + .../components/ui/drawer/drawer-footer.svelte | 26 + .../components/ui/drawer/drawer-header.svelte | 26 + .../components/ui/drawer/drawer-title.svelte | 25 + .../lib/components/ui/drawer/drawer.svelte | 26 + web/src/lib/components/ui/drawer/index.ts | 25 + web/src/lib/components/ui/popover/index.ts | 19 + .../ui/popover/popover-close.svelte | 7 + .../ui/popover/popover-content.svelte | 31 + .../ui/popover/popover-portal.svelte | 7 + .../ui/popover/popover-trigger.svelte | 17 + .../lib/components/ui/popover/popover.svelte | 7 + .../lib/components/ui/range-calendar/index.ts | 40 ++ .../range-calendar-caption.svelte | 76 +++ .../range-calendar/range-calendar-cell.svelte | 19 + .../range-calendar/range-calendar-day.svelte | 39 ++ .../range-calendar-grid-body.svelte | 7 + .../range-calendar-grid-head.svelte | 7 + .../range-calendar-grid-row.svelte | 12 + .../range-calendar/range-calendar-grid.svelte | 16 + .../range-calendar-head-cell.svelte | 19 + .../range-calendar-header.svelte | 19 + .../range-calendar-heading.svelte | 16 + .../range-calendar-month-select.svelte | 44 ++ .../range-calendar-month.svelte | 15 + .../range-calendar-months.svelte | 19 + .../range-calendar/range-calendar-nav.svelte | 19 + .../range-calendar-next-button.svelte | 31 + .../range-calendar-prev-button.svelte | 31 + .../range-calendar-year-select.svelte | 43 ++ .../ui/range-calendar/range-calendar.svelte | 112 ++++ web/src/routes/+layout.svelte | 70 ++- web/src/routes/+page.svelte | 48 +- web/src/routes/analysis/+page.svelte | 192 +++++-- web/src/routes/api/[...path]/+server.ts | 39 ++ web/src/routes/bills/+page.svelte | 533 +++++++++++------- web/src/routes/download/[...path]/+server.ts | 19 + web/svelte.config.js | 20 +- web/vite.config.ts | 17 + web/yarn.lock | 128 ++++- 96 files changed, 4301 insertions(+), 482 deletions(-) create mode 100644 README.md create mode 100644 TODO.md create mode 100644 analyzer/Dockerfile create mode 100644 analyzer/server.py create mode 100644 server/Dockerfile create mode 100644 server/adapter/adapter.go create mode 100644 server/adapter/global.go create mode 100644 server/adapter/http/cleaner.go create mode 100644 server/adapter/python/cleaner.go create mode 100644 server/handler/bills.go create mode 100644 server/repository/global.go create mode 100644 server/repository/repository.go create mode 100644 server/router/router.go create mode 100644 web/Dockerfile create mode 100644 web/src/lib/components/ui/calendar/calendar-caption.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-cell.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-day.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-grid-body.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-grid-head.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-grid-row.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-grid.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-head-cell.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-header.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-heading.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-month-select.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-month.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-months.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-nav.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-next-button.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-prev-button.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar-year-select.svelte create mode 100644 web/src/lib/components/ui/calendar/calendar.svelte create mode 100644 web/src/lib/components/ui/calendar/index.ts create mode 100644 web/src/lib/components/ui/date-range-picker/date-range-picker.svelte create mode 100644 web/src/lib/components/ui/date-range-picker/index.ts create mode 100644 web/src/lib/components/ui/drawer/drawer-close.svelte create mode 100644 web/src/lib/components/ui/drawer/drawer-content.svelte create mode 100644 web/src/lib/components/ui/drawer/drawer-description.svelte create mode 100644 web/src/lib/components/ui/drawer/drawer-footer.svelte create mode 100644 web/src/lib/components/ui/drawer/drawer-header.svelte create mode 100644 web/src/lib/components/ui/drawer/drawer-title.svelte create mode 100644 web/src/lib/components/ui/drawer/drawer.svelte create mode 100644 web/src/lib/components/ui/drawer/index.ts create mode 100644 web/src/lib/components/ui/popover/index.ts create mode 100644 web/src/lib/components/ui/popover/popover-close.svelte create mode 100644 web/src/lib/components/ui/popover/popover-content.svelte create mode 100644 web/src/lib/components/ui/popover/popover-portal.svelte create mode 100644 web/src/lib/components/ui/popover/popover-trigger.svelte create mode 100644 web/src/lib/components/ui/popover/popover.svelte create mode 100644 web/src/lib/components/ui/range-calendar/index.ts create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-caption.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-cell.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-day.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-grid.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-header.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-heading.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-month.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-months.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-nav.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte create mode 100644 web/src/lib/components/ui/range-calendar/range-calendar.svelte create mode 100644 web/src/routes/api/[...path]/+server.ts create mode 100644 web/src/routes/download/[...path]/+server.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a45f6a --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +# 💰 BillAI - 智能账单分析系统 + +一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。 + +![架构](https://img.shields.io/badge/架构-微服务-blue) +![Go](https://img.shields.io/badge/Go-1.21-00ADD8) +![Python](https://img.shields.io/badge/Python-3.12-3776AB) +![Svelte](https://img.shields.io/badge/SvelteKit-5-FF3E00) +![MongoDB](https://img.shields.io/badge/MongoDB-8.0-47A248) +![Docker](https://img.shields.io/badge/Docker-Compose-2496ED) + +## ✨ 功能特性 + +- 📊 **账单分析** - 自动解析微信/支付宝账单,生成可视化报表 +- 🏷️ **智能分类** - 基于关键词匹配的交易分类推断 +- 📈 **趋势图表** - 日/月消费趋势、分类排行、收支对比 +- 🔍 **复核修正** - 对不确定的分类进行人工复核 +- 🐳 **一键部署** - Docker Compose 快速启动全部服务 + +## 🏗️ 系统架构 + +```mermaid +graph TB + subgraph 用户层 + User[👤 用户] + end + + subgraph 前端服务 + Web[🌐 Web 前端
SvelteKit :3000] + end + + subgraph 后端服务 + Server[⚙️ Go 后端
Gin :8080] + Analyzer[🐍 Python 分析服务
FastAPI :8001] + end + + subgraph 数据层 + MongoDB[(🍃 MongoDB
:27017)] + MongoExpress[📊 Mongo Express
:8083] + end + + User -->|访问| Web + Web -->|HTTP API| Server + Server -->|HTTP 调用| Analyzer + Server -->|读写数据| MongoDB + MongoExpress -->|管理| MongoDB + + style Web fill:#ff3e00,color:#fff + style Server fill:#00ADD8,color:#fff + style Analyzer fill:#3776AB,color:#fff + style MongoDB fill:#47A248,color:#fff +``` + +### 数据流 + +```mermaid +sequenceDiagram + participant U as 👤 用户 + participant W as 🌐 Web + participant S as ⚙️ Server + participant A as 🐍 Analyzer + participant D as 🍃 MongoDB + + rect rgb(240, 248, 255) + Note over U,D: 上传账单流程 + U->>W: 上传账单文件 + W->>S: POST /api/upload + S->>A: POST /clean (清洗账单) + A-->>S: 清洗结果 + 分类 + S->>D: 存储账单数据 + D-->>S: 保存成功 + S-->>W: 返回分析结果 + W-->>U: 显示分析报表 + end + + rect rgb(255, 248, 240) + Note over U,D: 复核修正流程 + U->>W: 查看待复核记录 + W->>S: GET /api/review + S->>D: 查询不确定分类 + D-->>S: 返回记录列表 + S-->>W: 待复核数据 + W-->>U: 显示复核界面 + U->>W: 修正分类 + W->>S: 更新分类 + S->>D: 更新记录 + end +``` + +## 📁 项目结构 + +``` +BillAI/ +├── web/ # 前端 (SvelteKit + TailwindCSS) +│ ├── src/ +│ │ ├── routes/ # 页面路由 +│ │ │ ├── analysis/ # 📊 账单分析页 +│ │ │ ├── bills/ # 📋 账单列表页 +│ │ │ └── review/ # ✅ 复核页面 +│ │ └── lib/ +│ │ ├── components/ # UI 组件 +│ │ └── services/ # API 服务 +│ └── Dockerfile +│ +├── server/ # 后端 (Go + Gin) +│ ├── adapter/ # 适配器层 +│ │ ├── http/ # HTTP 客户端 +│ │ └── python/ # 子进程调用 +│ ├── handler/ # HTTP 处理器 +│ ├── service/ # 业务逻辑 +│ ├── repository/ # 数据访问层 +│ └── Dockerfile +│ +├── analyzer/ # 分析服务 (Python + FastAPI) +│ ├── server.py # FastAPI 入口 +│ ├── clean_bill.py # 账单清洗 +│ ├── category.py # 分类推断 +│ ├── cleaners/ # 清洗器 +│ │ ├── alipay.py # 支付宝 +│ │ └── wechat.py # 微信 +│ └── Dockerfile +│ +├── data/ # 测试数据目录 +├── mongo/ # MongoDB 数据 +└── docker-compose.yaml # 容器编排 +``` + +## 🚀 快速开始 + +### 环境要求 + +- Docker & Docker Compose +- (可选) Go 1.21+、Python 3.12+、Node.js 20+ + +### 一键启动 + +```bash +# 克隆项目 +git clone https://github.com/your-username/BillAI.git +cd BillAI + +# 启动所有服务 +docker-compose up -d --build + +# 查看服务状态 +docker-compose ps +``` + +### 访问地址 + +| 服务 | 地址 | 说明 | +|------|------|------| +| **前端页面** | http://localhost:3000 | Web 界面 | +| **后端 API** | http://localhost:8080 | RESTful API | +| **分析服务** | http://localhost:8001 | Python API | +| **Mongo Express** | http://localhost:8083 | 数据库管理 | + +## 💻 本地开发 + +### 前端开发 + +```bash +cd web +yarn install +yarn dev +``` + +### 后端开发 + +```bash +cd server +go mod download +go run . +``` + +### 分析服务开发 + +```bash +cd analyzer +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +python server.py +``` + +## 📖 API 文档 + +### 后端 API (Go) + +| 方法 | 路径 | 说明 | +|------|------|------| +| `POST` | `/api/upload` | 上传并分析账单 | +| `GET` | `/api/review` | 获取待复核记录 | +| `GET` | `/health` | 健康检查 | + +### 分析服务 API (Python) + +| 方法 | 路径 | 说明 | +|------|------|------| +| `POST` | `/clean` | 清洗账单文件 | +| `POST` | `/category/infer` | 推断交易分类 | +| `GET` | `/category/list` | 获取分类列表 | +| `POST` | `/detect` | 检测账单类型 | +| `GET` | `/health` | 健康检查 | + +## ⚙️ 配置说明 + +### 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `ANALYZER_URL` | `http://localhost:8001` | Python 分析服务地址 | +| `ANALYZER_MODE` | `http` | 适配器模式: http/subprocess | +| `MONGO_URI` | `mongodb://localhost:27017` | MongoDB 连接 URI | +| `MONGO_DATABASE` | `billai` | 数据库名称 | + +### 配置文件 + +- `server/config.yaml` - Go 后端配置 +- `analyzer/config/category.yaml` - 分类规则配置 + +## 🔧 技术栈 + +| 层级 | 技术 | 版本 | +|------|------|------| +| **前端** | SvelteKit + TailwindCSS | 5.x / 4.x | +| **后端** | Go + Gin | 1.21 / 1.9 | +| **分析服务** | Python + FastAPI | 3.12 / 0.109+ | +| **数据库** | MongoDB | 8.0 | +| **容器化** | Docker Compose | - | + +## 📊 支持的账单格式 + +- ✅ **微信支付** - 微信支付账单流水文件 (CSV) +- ✅ **支付宝** - 支付宝交易明细 (CSV) + +## 🛣️ 路线图 + +- [ ] 添加用户认证 (JWT) +- [ ] 支持更多账单格式(银行账单) +- [ ] AI 智能分类(LLM) +- [ ] 预算管理功能 +- [ ] 移动端适配 +- [ ] 数据导出 (Excel/PDF) + +## 🤝 贡献指南 + +欢迎提交 Issue 和 Pull Request! + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/amazing-feature`) +3. 提交更改 (`git commit -m 'Add amazing feature'`) +4. 推送分支 (`git push origin feature/amazing-feature`) +5. 创建 Pull Request + +## 📄 许可证 + +本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件 + +## 🙏 致谢 + +- [SvelteKit](https://kit.svelte.dev/) +- [Gin](https://gin-gonic.com/) +- [FastAPI](https://fastapi.tiangolo.com/) +- [shadcn-svelte](https://shadcn-svelte.com/) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..cb9c320 --- /dev/null +++ b/TODO.md @@ -0,0 +1,101 @@ +# BillAI 开发计划 + +## 已完成功能 + +### 前端 (web) +- [x] 侧边栏导航布局 +- [x] 上传账单页面 +- [x] 智能复核页面 +- [x] 账单管理页面(分页、筛选、响应式表格) +- [x] 数据分析页面(图表、统计) +- [x] 日期范围选择器 (DateRangePicker) +- [x] 主题切换(亮色/暗色/跟随系统) +- [x] 服务状态指示器(轮询检查) +- [x] 顶部导航栏(页面标题、状态指示) +- [x] shadcn-svelte UI 组件库集成 + +### 后端 (server) +- [x] 账单上传与解析 +- [x] 智能分类(Python 分析器) +- [x] 复核记录查询 +- [x] 账单列表 API(分页、筛选) +- [x] 健康检查端点 +- [x] MongoDB 数据存储 + +### 分析器 (analyzer) +- [x] 支付宝账单解析 +- [x] 微信账单解析 +- [x] 分类规则引擎 +- [x] 重复记录检测 + +--- + +## 待实现功能 + +### 高优先级 + +- [ ] **SSE 实时状态推送** + - 服务器实现 `/events` SSE 端点 + - 前端使用 EventSource 接收状态 + - 支持服务状态、任务进度等实时推送 + +- [ ] **服务异常页面提示** + - 服务离线时显示遮罩层 + - 提示用户检查服务器状态 + - 自动重试连接 + +### 中优先级 + +- [ ] **账单编辑功能** + - 在账单管理页面编辑记录 + - 修改分类、备注等字段 + - 保存到数据库 + +- [ ] **账单删除功能** + - 单条删除 + - 批量删除 + - 删除确认对话框 + +- [ ] **数据导出** + - 导出为 CSV + - 导出为 Excel + - 自定义导出字段 + +- [ ] **分类管理** + - 自定义分类 + - 分类图标配置 + - 分类规则编辑 + +### 低优先级 + +- [ ] **用户认证** + - 登录/注册 + - 多用户支持 + - 权限管理 + +- [ ] **数据备份** + - 自动备份 + - 导入/导出备份 + +- [ ] **移动端适配** + - PWA 支持 + - 触摸手势优化 + +- [ ] **AI 智能分析** + - 消费趋势预测 + - 异常消费提醒 + - 智能预算建议 + +--- + +## 技术债务 + +- [ ] 统一错误处理 +- [ ] 添加单元测试 +- [ ] API 文档(Swagger) +- [ ] 日志系统完善 +- [ ] 性能优化(大数据量分页) + +--- + +*最后更新: 2026-01-10* diff --git a/analyzer/Dockerfile b/analyzer/Dockerfile new file mode 100644 index 0000000..5c80167 --- /dev/null +++ b/analyzer/Dockerfile @@ -0,0 +1,25 @@ +# Python 分析服务 Dockerfile +FROM python:3.12-slim + +WORKDIR /app + +# 配置国内镜像源(pip + apt) +RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources && \ + pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com + +# 安装依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制源代码 +COPY . . + +# 暴露端口 +EXPOSE 8001 + +# 健康检查需要 curl +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# 启动服务 +CMD ["python", "server.py"] diff --git a/analyzer/cleaners/alipay.py b/analyzer/cleaners/alipay.py index 3a144df..95c3b4f 100644 --- a/analyzer/cleaners/alipay.py +++ b/analyzer/cleaners/alipay.py @@ -60,6 +60,8 @@ class AlipayCleaner(BaseCleaner): print(f"\n处理结果:") print(f" 全额退款删除: {self.stats['fully_refunded']} 条") print(f" 部分退款调整: {self.stats['partially_refunded']} 条") + if self.stats.get("zero_amount", 0) > 0: + print(f" 0元记录过滤: {self.stats['zero_amount']} 条") print(f" 最终保留行数: {len(final_rows)}") # 第五步:重新分类并添加"需复核"标注 @@ -134,7 +136,11 @@ class AlipayCleaner(BaseCleaner): self.stats["partially_refunded"] += 1 print(f" 部分退款: {row[0]} | {row[2]} | 原{expense_amount}元 -> {format_amount(remaining)}元") else: - final_rows.append(row) + # 过滤掉金额为 0 的记录(预下单/加购物车等无效记录) + if expense_amount > 0: + final_rows.append(row) + else: + self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1 else: final_rows.append(row) diff --git a/analyzer/cleaners/base.py b/analyzer/cleaners/base.py index 43e7989..8a2e1c7 100644 --- a/analyzer/cleaners/base.py +++ b/analyzer/cleaners/base.py @@ -85,6 +85,58 @@ def compute_date_range(args) -> tuple[date | None, date | None]: return start_date, end_date +def compute_date_range_from_values( + year: str = None, + month: str = None, + start: str = None, + end: str = None +) -> tuple[date | None, date | None]: + """ + 根据参数值计算日期范围(不依赖 argparse) + 供 HTTP API 调用使用 + + Returns: + (start_date, end_date) 或 (None, None) 表示不筛选 + """ + start_date = None + end_date = None + + # 1. 根据年份设置范围 + if year: + y = int(year) + start_date = date(y, 1, 1) + end_date = date(y, 12, 31) + + # 2. 根据月份进一步收窄 + if month: + m = int(month) + y = int(year) if year else datetime.now().year + + if not start_date: + start_date = date(y, 1, 1) + end_date = date(y, 12, 31) + + month_start = date(y, m, 1) + if m == 12: + month_end = date(y, 12, 31) + else: + month_end = date(y, m + 1, 1) - timedelta(days=1) + + start_date = max(start_date, month_start) if start_date else month_start + end_date = min(end_date, month_end) if end_date else month_end + + # 3. 根据 start/end 参数进一步收窄 + if start: + custom_start = parse_date(start) + start_date = max(start_date, custom_start) if start_date else custom_start + + if end: + custom_end = parse_date(end) + end_date = min(end_date, custom_end) if end_date else custom_end + + return start_date, end_date + + def is_in_date_range(date_str: str, start_date: date | None, end_date: date | None) -> bool: """检查日期字符串是否在指定范围内""" if start_date is None and end_date is None: diff --git a/analyzer/cleaners/wechat.py b/analyzer/cleaners/wechat.py index 87bf1f2..cef9d83 100644 --- a/analyzer/cleaners/wechat.py +++ b/analyzer/cleaners/wechat.py @@ -58,6 +58,8 @@ class WechatCleaner(BaseCleaner): print(f"\n处理结果:") print(f" 全额退款删除: {self.stats['fully_refunded']} 条") print(f" 部分退款调整: {self.stats['partially_refunded']} 条") + if self.stats.get("zero_amount", 0) > 0: + print(f" 0元记录过滤: {self.stats['zero_amount']} 条") print(f" 保留支出条目: {len(final_expense_rows)} 条") print(f" 保留收入条目: {len(income_rows)} 条") @@ -177,7 +179,11 @@ class WechatCleaner(BaseCleaner): if merchant in transfer_refunds: del transfer_refunds[merchant] else: - final_expense_rows.append((row, None)) + # 过滤掉金额为 0 的记录(预下单/加购物车等无效记录) + if original_amount > 0: + final_expense_rows.append((row, None)) + else: + self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1 return final_expense_rows, income_rows diff --git a/analyzer/requirements.txt b/analyzer/requirements.txt index a08df0d..3dde17e 100644 --- a/analyzer/requirements.txt +++ b/analyzer/requirements.txt @@ -1,2 +1,4 @@ pyyaml>=6.0 - +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +python-multipart>=0.0.6 diff --git a/analyzer/server.py b/analyzer/server.py new file mode 100644 index 0000000..799c284 --- /dev/null +++ b/analyzer/server.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +""" +账单分析 FastAPI 服务 + +提供 HTTP API 供 Go 服务调用,替代子进程通信方式 +""" +import os +import sys +import io +import tempfile +import shutil +from pathlib import Path +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse, JSONResponse +from pydantic import BaseModel +from typing import Optional + +# 解决编码问题 +if sys.stdout.encoding != 'utf-8': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + +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 + + +# ============================================================================= +# Pydantic 模型 +# ============================================================================= + +class CleanRequest(BaseModel): + """清洗请求""" + input_path: str + output_path: str + year: Optional[str] = None + month: Optional[str] = None + start: Optional[str] = None + end: Optional[str] = None + format: Optional[str] = "csv" + bill_type: Optional[str] = "auto" # auto, alipay, wechat + + +class CleanResponse(BaseModel): + """清洗响应""" + success: bool + bill_type: str + message: str + output_path: Optional[str] = None + + +class CategoryRequest(BaseModel): + """分类推断请求""" + merchant: str + product: str + income_expense: str # "收入" 或 "支出" + + +class CategoryResponse(BaseModel): + """分类推断响应""" + category: str + is_certain: bool + + +class HealthResponse(BaseModel): + """健康检查响应""" + status: str + version: str + + +# ============================================================================= +# 辅助函数 +# ============================================================================= + +def detect_bill_type(filepath: str) -> str | None: + """ + 检测账单类型 + + Returns: + 'alipay' | 'wechat' | None + """ + try: + with open(filepath, "r", encoding="utf-8") as f: + for _ in range(20): + line = f.readline() + if not line: + break + + # 支付宝特征 + if "交易分类" in line and "对方账号" in line: + return "alipay" + + # 微信特征 + if "交易类型" in line and "金额(元)" in line: + return "wechat" + + # 数据行特征 + if line.startswith("202"): + if "¥" in line: + return "wechat" + if "@" in line: + return "alipay" + + except Exception as e: + print(f"读取文件失败: {e}", file=sys.stderr) + return None + + return None + + +def do_clean( + input_path: str, + output_path: str, + bill_type: str = "auto", + year: str = None, + month: str = None, + start: str = None, + end: str = None, + output_format: str = "csv" +) -> tuple[bool, str, str]: + """ + 执行清洗逻辑 + + Returns: + (success, bill_type, message) + """ + # 检查文件是否存在 + if not Path(input_path).exists(): + return False, "", f"文件不存在: {input_path}" + + # 检测账单类型 + if bill_type == "auto": + detected_type = detect_bill_type(input_path) + if detected_type is None: + return False, "", "无法识别账单类型" + bill_type = detected_type + + # 计算日期范围 + start_date, end_date = compute_date_range_from_values(year, month, start, end) + + # 创建对应的清理器 + try: + if bill_type == "alipay": + cleaner = AlipayCleaner(input_path, output_path, output_format) + else: + cleaner = WechatCleaner(input_path, output_path, output_format) + + cleaner.set_date_range(start_date, end_date) + cleaner.clean() + + type_names = {"alipay": "支付宝", "wechat": "微信"} + return True, bill_type, f"✅ {type_names[bill_type]}账单清洗完成" + + except Exception as e: + return False, bill_type, f"清洗失败: {str(e)}" + + +# ============================================================================= +# FastAPI 应用 +# ============================================================================= + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + print("🚀 账单分析服务启动") + yield + print("👋 账单分析服务关闭") + + +app = FastAPI( + title="BillAI Analyzer", + description="账单分析与清洗服务", + version="1.0.0", + lifespan=lifespan +) + + +# ============================================================================= +# API 路由 +# ============================================================================= + +@app.get("/health", response_model=HealthResponse) +async def health_check(): + """健康检查""" + return HealthResponse(status="ok", version="1.0.0") + + +@app.post("/clean", response_model=CleanResponse) +async def clean_bill(request: CleanRequest): + """ + 清洗账单文件 + + 接收账单文件路径,执行清洗后输出到指定路径 + """ + success, bill_type, message = do_clean( + input_path=request.input_path, + output_path=request.output_path, + bill_type=request.bill_type or "auto", + year=request.year, + month=request.month, + start=request.start, + end=request.end, + output_format=request.format or "csv" + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return CleanResponse( + success=True, + bill_type=bill_type, + message=message, + output_path=request.output_path + ) + + +@app.post("/clean/upload", response_model=CleanResponse) +async def clean_bill_upload( + file: UploadFile = File(...), + year: Optional[str] = Form(None), + month: Optional[str] = Form(None), + start: Optional[str] = Form(None), + end: Optional[str] = Form(None), + format: Optional[str] = Form("csv"), + bill_type: Optional[str] = Form("auto") +): + """ + 上传并清洗账单文件 + + 通过 multipart/form-data 上传文件,清洗后返回结果 + """ + # 创建临时文件 + suffix = Path(file.filename).suffix or ".csv" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_input: + shutil.copyfileobj(file.file, tmp_input) + input_path = tmp_input.name + + # 创建输出临时文件 + output_suffix = ".json" if format == "json" else ".csv" + with tempfile.NamedTemporaryFile(delete=False, suffix=output_suffix) as tmp_output: + output_path = tmp_output.name + + try: + success, detected_type, message = do_clean( + input_path=input_path, + output_path=output_path, + bill_type=bill_type or "auto", + year=year, + month=month, + start=start, + end=end, + output_format=format or "csv" + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return CleanResponse( + success=True, + bill_type=detected_type, + message=message, + output_path=output_path + ) + + finally: + # 清理输入临时文件 + if os.path.exists(input_path): + os.unlink(input_path) + + +@app.get("/clean/download/{file_path:path}") +async def download_cleaned_file(file_path: str): + """下载清洗后的文件""" + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="文件不存在") + + return FileResponse( + file_path, + filename=Path(file_path).name, + media_type="application/octet-stream" + ) + + +@app.post("/category/infer", response_model=CategoryResponse) +async def infer_category_api(request: CategoryRequest): + """ + 推断交易分类 + + 根据商户名称和商品信息推断交易分类 + """ + category, is_certain = infer_category( + merchant=request.merchant, + product=request.product, + income_expense=request.income_expense + ) + + return CategoryResponse(category=category, is_certain=is_certain) + + +@app.get("/category/list") +async def list_categories(): + """获取所有分类列表""" + return { + "expense": get_all_categories(), + "income": get_all_income_categories() + } + + +@app.post("/detect") +async def detect_bill_type_api(file: UploadFile = File(...)): + """ + 检测账单类型 + + 上传文件后自动检测是支付宝还是微信账单 + """ + suffix = Path(file.filename).suffix or ".csv" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + shutil.copyfileobj(file.file, tmp) + tmp_path = tmp.name + + try: + bill_type = detect_bill_type(tmp_path) + if bill_type is None: + raise HTTPException(status_code=400, detail="无法识别账单类型") + + type_names = {"alipay": "支付宝", "wechat": "微信"} + return { + "bill_type": bill_type, + "display_name": type_names[bill_type] + } + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +# ============================================================================= +# 启动入口 +# ============================================================================= + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("ANALYZER_PORT", 8001)) + host = os.environ.get("ANALYZER_HOST", "0.0.0.0") + + print(f"🚀 启动账单分析服务: http://{host}:{port}") + uvicorn.run(app, host=host, port=port) diff --git a/docker-compose.yaml b/docker-compose.yaml index 93e4f07..aed7a02 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,81 @@ services: + # SvelteKit 前端服务 + web: + build: + context: ./web + dockerfile: Dockerfile + container_name: billai-web + restart: unless-stopped + ports: + - "3000:3000" + environment: + NODE_ENV: production + HOST: "0.0.0.0" + PORT: "3000" + # SSR 服务端请求后端的地址(Docker 内部网络) + API_URL: "http://server:8080" + depends_on: + server: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + + # Go 后端服务 + server: + build: + context: ./server + dockerfile: Dockerfile + container_name: billai-server + restart: unless-stopped + ports: + - "8080:8080" + environment: + ANALYZER_URL: "http://analyzer:8001" + ANALYZER_MODE: "http" + MONGO_URI: "mongodb://admin:password@mongodb:27017" + MONGO_DATABASE: "billai" + volumes: + - ./server/uploads:/app/uploads + - ./server/outputs:/app/outputs + depends_on: + analyzer: + condition: service_healthy + mongodb: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # Python 分析服务 (FastAPI) + analyzer: + build: + context: ./analyzer + dockerfile: Dockerfile + container_name: billai-analyzer + restart: unless-stopped + ports: + - "8001:8001" + environment: + ANALYZER_HOST: "0.0.0.0" + ANALYZER_PORT: "8001" + volumes: + # 共享数据目录,用于访问上传的文件 + - ./server/uploads:/app/uploads + - ./server/outputs:/app/outputs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8001/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + mongodb: image: mongo:8.0 container_name: billai-mongodb diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..458f4a6 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,49 @@ +# Go 服务 Dockerfile +# 多阶段构建:编译阶段 + 运行阶段 + +# ===== 编译阶段 ===== +FROM golang:1.21-alpine AS builder + +WORKDIR /build + +# 配置 Go 代理(国内镜像) +ENV GOPROXY=https://goproxy.cn,direct + +# 先复制依赖文件,利用 Docker 缓存 +COPY go.mod go.sum ./ +RUN go mod download + +# 复制源代码并编译 +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o billai-server . + +# ===== 运行阶段 ===== +FROM alpine:latest + +WORKDIR /app + +# 配置 Alpine 镜像源(国内) +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +# 安装必要工具 +RUN apk --no-cache add ca-certificates tzdata curl + +# 设置时区 +ENV TZ=Asia/Shanghai + +# 从编译阶段复制二进制文件 +COPY --from=builder /build/billai-server . +COPY --from=builder /build/config.yaml . + +# 创建必要目录 +RUN mkdir -p uploads outputs + +# 暴露端口 +EXPOSE 8080 + +# 健康检查 +HEALTHCHECK --interval=10s --timeout=5s --retries=5 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# 启动服务 +CMD ["./billai-server"] diff --git a/server/adapter/adapter.go b/server/adapter/adapter.go new file mode 100644 index 0000000..b061304 --- /dev/null +++ b/server/adapter/adapter.go @@ -0,0 +1,28 @@ +// Package adapter 定义与外部系统交互的抽象接口 +// 这样可以方便后续更换通信方式(如从子进程调用改为 HTTP/gRPC/消息队列等) +package adapter + +// CleanOptions 清洗选项 +type CleanOptions struct { + Year string // 年份筛选 + Month string // 月份筛选 + Start string // 起始日期 + End string // 结束日期 + Format string // 输出格式: csv/json +} + +// CleanResult 清洗结果 +type CleanResult struct { + BillType string // 检测到的账单类型: alipay/wechat + Output string // 脚本输出信息 +} + +// Cleaner 账单清洗器接口 +// 负责将原始账单数据清洗为标准格式 +type Cleaner interface { + // Clean 执行账单清洗 + // inputPath: 输入文件路径 + // outputPath: 输出文件路径 + // opts: 清洗选项 + Clean(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) +} diff --git a/server/adapter/global.go b/server/adapter/global.go new file mode 100644 index 0000000..5087a37 --- /dev/null +++ b/server/adapter/global.go @@ -0,0 +1,15 @@ +// Package adapter 全局适配器实例管理 +package adapter + +// 全局清洗器实例 +var globalCleaner Cleaner + +// SetCleaner 设置全局清洗器实例 +func SetCleaner(c Cleaner) { + globalCleaner = c +} + +// GetCleaner 获取全局清洗器实例 +func GetCleaner() Cleaner { + return globalCleaner +} diff --git a/server/adapter/http/cleaner.go b/server/adapter/http/cleaner.go new file mode 100644 index 0000000..da60cb1 --- /dev/null +++ b/server/adapter/http/cleaner.go @@ -0,0 +1,204 @@ +// Package http 实现通过 HTTP API 调用 Python 服务的清洗器 +package http + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" + + "billai-server/adapter" +) + +// CleanRequest HTTP 清洗请求 +type CleanRequest struct { + InputPath string `json:"input_path"` + OutputPath string `json:"output_path"` + Year string `json:"year,omitempty"` + Month string `json:"month,omitempty"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + Format string `json:"format,omitempty"` + BillType string `json:"bill_type,omitempty"` +} + +// CleanResponse HTTP 清洗响应 +type CleanResponse struct { + Success bool `json:"success"` + BillType string `json:"bill_type"` + Message string `json:"message"` + OutputPath string `json:"output_path,omitempty"` +} + +// ErrorResponse 错误响应 +type ErrorResponse struct { + Detail string `json:"detail"` +} + +// Cleaner 通过 HTTP API 调用 Python 服务的清洗器实现 +type Cleaner struct { + baseURL string // Python 服务基础 URL + httpClient *http.Client // HTTP 客户端 +} + +// NewCleaner 创建 HTTP 清洗器 +func NewCleaner(baseURL string) *Cleaner { + return &Cleaner{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 60 * time.Second, // 清洗可能需要较长时间 + }, + } +} + +// NewCleanerWithClient 使用自定义 HTTP 客户端创建清洗器 +func NewCleanerWithClient(baseURL string, client *http.Client) *Cleaner { + return &Cleaner{ + baseURL: baseURL, + httpClient: client, + } +} + +// Clean 执行账单清洗(使用文件上传方式) +func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) { + // 打开输入文件 + file, err := os.Open(inputPath) + if err != nil { + return nil, 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 nil, fmt.Errorf("创建表单文件失败: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("复制文件内容失败: %w", err) + } + + // 添加其他参数 + if opts != nil { + if opts.Year != "" { + writer.WriteField("year", opts.Year) + } + if opts.Month != "" { + writer.WriteField("month", opts.Month) + } + if opts.Start != "" { + writer.WriteField("start", opts.Start) + } + if opts.End != "" { + writer.WriteField("end", opts.End) + } + if opts.Format != "" { + writer.WriteField("format", opts.Format) + } + } + writer.WriteField("bill_type", "auto") + writer.Close() + + // 发送上传请求 + fmt.Printf("🌐 调用清洗服务: %s/clean/upload\n", c.baseURL) + req, err := http.NewRequest("POST", c.baseURL+"/clean/upload", &body) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP 请求失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + // 处理错误响应 + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(respBody, &errResp); err == nil { + return nil, fmt.Errorf("清洗失败: %s", errResp.Detail) + } + return nil, fmt.Errorf("清洗失败: HTTP %d - %s", resp.StatusCode, string(respBody)) + } + + // 解析成功响应 + var cleanResp CleanResponse + if err := json.Unmarshal(respBody, &cleanResp); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + // 下载清洗后的文件 + if cleanResp.OutputPath != "" { + if err := c.downloadFile(cleanResp.OutputPath, outputPath); err != nil { + return nil, fmt.Errorf("下载清洗结果失败: %w", err) + } + } + + return &adapter.CleanResult{ + BillType: cleanResp.BillType, + Output: cleanResp.Message, + }, nil +} + +// downloadFile 下载清洗后的文件 +func (c *Cleaner) downloadFile(remotePath, localPath string) error { + // 构建下载 URL + downloadURL := fmt.Sprintf("%s/clean/download/%s", c.baseURL, remotePath) + + resp, err := c.httpClient.Get(downloadURL) + if err != nil { + return fmt.Errorf("下载请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode) + } + + // 创建本地文件 + out, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("创建文件失败: %w", err) + } + defer out.Close() + + // 写入文件内容 + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("写入文件失败: %w", err) + } + + return nil +} + +// HealthCheck 检查 Python 服务健康状态 +func (c *Cleaner) HealthCheck() error { + resp, err := c.httpClient.Get(c.baseURL + "/health") + if err != nil { + return fmt.Errorf("健康检查失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("服务不健康: HTTP %d", resp.StatusCode) + } + + return nil +} + +// 确保 Cleaner 实现了 adapter.Cleaner 接口 +var _ adapter.Cleaner = (*Cleaner)(nil) diff --git a/server/adapter/python/cleaner.go b/server/adapter/python/cleaner.go new file mode 100644 index 0000000..d08b4d2 --- /dev/null +++ b/server/adapter/python/cleaner.go @@ -0,0 +1,94 @@ +// Package python 实现通过子进程调用 Python 脚本的清洗器 +package python + +import ( + "fmt" + "os/exec" + "strings" + + "billai-server/adapter" + "billai-server/config" +) + +// Cleaner 通过子进程调用 Python 脚本的清洗器实现 +type Cleaner struct { + pythonPath string // Python 解释器路径 + scriptPath string // 清洗脚本路径 + workDir string // 工作目录 +} + +// NewCleaner 创建 Python 清洗器 +func NewCleaner() *Cleaner { + return &Cleaner{ + pythonPath: config.ResolvePath(config.Global.PythonPath), + scriptPath: config.ResolvePath(config.Global.CleanScript), + workDir: config.Global.ProjectRoot, + } +} + +// NewCleanerWithConfig 使用自定义配置创建 Python 清洗器 +func NewCleanerWithConfig(pythonPath, scriptPath, workDir string) *Cleaner { + return &Cleaner{ + pythonPath: pythonPath, + scriptPath: scriptPath, + workDir: workDir, + } +} + +// Clean 执行 Python 清洗脚本 +func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) { + // 构建命令参数 + args := []string{c.scriptPath, inputPath, outputPath} + + if opts != nil { + if opts.Year != "" { + args = append(args, "--year", opts.Year) + } + if opts.Month != "" { + args = append(args, "--month", opts.Month) + } + if opts.Start != "" { + args = append(args, "--start", opts.Start) + } + if opts.End != "" { + args = append(args, "--end", opts.End) + } + if opts.Format != "" { + args = append(args, "--format", opts.Format) + } + } + + // 执行 Python 脚本 + fmt.Printf("🐍 执行清洗脚本...\n") + cmd := exec.Command(c.pythonPath, args...) + cmd.Dir = c.workDir + + output, err := cmd.CombinedOutput() + outputStr := string(output) + + if err != nil { + return nil, fmt.Errorf("清洗脚本执行失败: %w\n输出: %s", err, outputStr) + } + + // 从输出中检测账单类型 + billType := detectBillTypeFromOutput(outputStr) + + return &adapter.CleanResult{ + BillType: billType, + Output: outputStr, + }, nil +} + +// detectBillTypeFromOutput 从 Python 脚本输出中检测账单类型 +func detectBillTypeFromOutput(output string) string { + if strings.Contains(output, "支付宝") { + return "alipay" + } + if strings.Contains(output, "微信") { + return "wechat" + } + return "" +} + +// 确保 Cleaner 实现了 adapter.Cleaner 接口 +var _ adapter.Cleaner = (*Cleaner)(nil) diff --git a/server/config.yaml b/server/config.yaml index 19a72c9..ad9a80e 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -4,13 +4,20 @@ server: port: 8080 -# Python 配置 +# Python 配置 (subprocess 模式使用) python: # Python 解释器路径(相对于项目根目录或绝对路径) - path: analyzer/venv/Scripts/python.exe + path: analyzer/venv/bin/python # 分析脚本路径(相对于项目根目录) script: analyzer/clean_bill.py +# Analyzer 服务配置 (HTTP 模式使用) +analyzer: + # Python 分析服务 URL + url: http://localhost:8001 + # 适配器模式: http (推荐) 或 subprocess + mode: http + # 文件目录配置(相对于项目根目录) directories: upload: server/uploads diff --git a/server/config/config.go b/server/config/config.go index 009a136..c256eeb 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -18,6 +18,10 @@ type Config struct { UploadDir string // 上传文件目录 OutputDir string // 输出文件目录 + // Analyzer 服务配置 (HTTP 模式) + AnalyzerURL string // Python 分析服务 URL + AnalyzerMode string // 适配器模式: http 或 subprocess + // MongoDB 配置 MongoURI string // MongoDB 连接 URI MongoDatabase string // 数据库名称 @@ -34,6 +38,10 @@ type configFile struct { Path string `yaml:"path"` Script string `yaml:"script"` } `yaml:"python"` + Analyzer struct { + URL string `yaml:"url"` + Mode string `yaml:"mode"` // http 或 subprocess + } `yaml:"analyzer"` Directories struct { Upload string `yaml:"upload"` Output string `yaml:"output"` @@ -116,6 +124,10 @@ func Load() { Global.UploadDir = "server/uploads" Global.OutputDir = "server/outputs" + // Analyzer 默认值 + Global.AnalyzerURL = getEnvOrDefault("ANALYZER_URL", "http://localhost:8001") + Global.AnalyzerMode = getEnvOrDefault("ANALYZER_MODE", "http") + // MongoDB 默认值 Global.MongoURI = getEnvOrDefault("MONGO_URI", "mongodb://localhost:27017") Global.MongoDatabase = getEnvOrDefault("MONGO_DATABASE", "billai") @@ -148,6 +160,13 @@ func Load() { if cfg.Directories.Output != "" { Global.OutputDir = cfg.Directories.Output } + // Analyzer 配置 + if cfg.Analyzer.URL != "" { + Global.AnalyzerURL = cfg.Analyzer.URL + } + if cfg.Analyzer.Mode != "" { + Global.AnalyzerMode = cfg.Analyzer.Mode + } // MongoDB 配置 if cfg.MongoDB.URI != "" { Global.MongoURI = cfg.MongoDB.URI @@ -173,6 +192,13 @@ func Load() { if root := os.Getenv("BILLAI_ROOT"); root != "" { Global.ProjectRoot = root } + // Analyzer 环境变量覆盖 + if url := os.Getenv("ANALYZER_URL"); url != "" { + Global.AnalyzerURL = url + } + if mode := os.Getenv("ANALYZER_MODE"); mode != "" { + Global.AnalyzerMode = mode + } // MongoDB 环境变量覆盖 if uri := os.Getenv("MONGO_URI"); uri != "" { Global.MongoURI = uri @@ -195,4 +221,3 @@ func ResolvePath(path string) string { } return filepath.Join(Global.ProjectRoot, path) } - diff --git a/server/handler/bills.go b/server/handler/bills.go new file mode 100644 index 0000000..1b43a07 --- /dev/null +++ b/server/handler/bills.go @@ -0,0 +1,165 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "billai-server/model" + "billai-server/repository" +) + +// ListBillsRequest 账单列表请求参数 +type ListBillsRequest struct { + Page int `form:"page"` // 页码,从 1 开始 + PageSize int `form:"page_size"` // 每页数量,默认 20 + StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD + EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD + Category string `form:"category"` // 分类筛选 + Type string `form:"type"` // 账单类型 alipay/wechat + IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出 +} + +// ListBillsResponse 账单列表响应 +type ListBillsResponse struct { + Result bool `json:"result"` + Message string `json:"message,omitempty"` + Data *ListBillsData `json:"data,omitempty"` +} + +// ListBillsData 账单列表数据 +type ListBillsData struct { + Total int64 `json:"total"` // 总记录数 + TotalExpense float64 `json:"total_expense"` // 筛选条件下的总支出 + TotalIncome float64 `json:"total_income"` // 筛选条件下的总收入 + Page int `json:"page"` // 当前页码 + PageSize int `json:"page_size"` // 每页数量 + Pages int `json:"pages"` // 总页数 + Bills []model.CleanedBill `json:"bills"` // 账单列表 +} + +// ListBills 获取清洗后的账单列表 +func ListBills(c *gin.Context) { + var req ListBillsRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, ListBillsResponse{ + Result: false, + Message: "参数解析失败: " + err.Error(), + }) + return + } + + // 设置默认值 + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 20 + } + if req.PageSize > 100 { + req.PageSize = 100 // 限制最大每页数量 + } + + // 构建筛选条件 + filter := make(map[string]interface{}) + + // 时间范围筛选 + if req.StartDate != "" || req.EndDate != "" { + timeFilter := make(map[string]interface{}) + if req.StartDate != "" { + startTime, err := time.Parse("2006-01-02", req.StartDate) + if err == nil { + timeFilter["$gte"] = startTime + } + } + if req.EndDate != "" { + endTime, err := time.Parse("2006-01-02", req.EndDate) + if err == nil { + // 结束日期包含当天,所以加一天 + endTime = endTime.Add(24 * time.Hour) + timeFilter["$lt"] = endTime + } + } + if len(timeFilter) > 0 { + filter["time"] = timeFilter + } + } + + // 分类筛选 + if req.Category != "" { + filter["category"] = req.Category + } + + // 账单类型筛选 + if req.Type != "" { + filter["bill_type"] = req.Type + } + + // 收支类型筛选 + if req.IncomeExpense != "" { + filter["income_expense"] = req.IncomeExpense + } + + // 获取数据 + repo := repository.GetRepository() + if repo == nil { + c.JSON(http.StatusInternalServerError, ListBillsResponse{ + Result: false, + Message: "数据库未连接", + }) + return + } + + // 获取账单列表(带分页) + bills, total, err := repo.GetCleanedBillsPaged(filter, req.Page, req.PageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, ListBillsResponse{ + Result: false, + Message: "查询失败: " + err.Error(), + }) + return + } + + // 获取聚合统计 + totalExpense, totalIncome, err := repo.GetBillsAggregate(filter) + if err != nil { + c.JSON(http.StatusInternalServerError, ListBillsResponse{ + Result: false, + Message: "统计失败: " + err.Error(), + }) + return + } + + // 计算总页数 + pages := int(total) / req.PageSize + if int(total)%req.PageSize > 0 { + pages++ + } + + c.JSON(http.StatusOK, ListBillsResponse{ + Result: true, + Data: &ListBillsData{ + Total: total, + TotalExpense: totalExpense, + TotalIncome: totalIncome, + Page: req.Page, + PageSize: req.PageSize, + Pages: pages, + Bills: bills, + }, + }) +} + +// parsePageParam 解析分页参数 +func parsePageParam(s string, defaultVal int) int { + if s == "" { + return defaultVal + } + val, err := strconv.Atoi(s) + if err != nil || val < 1 { + return defaultVal + } + return val +} diff --git a/server/handler/upload.go b/server/handler/upload.go index b26ed80..b7ad193 100644 --- a/server/handler/upload.go +++ b/server/handler/upload.go @@ -6,7 +6,6 @@ import ( "net/http" "os" "path/filepath" - "strings" "time" "github.com/gin-gonic/gin" @@ -36,6 +35,23 @@ 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. 保存上传的文件 timestamp := time.Now().Format("20060102_150405") inputFileName := fmt.Sprintf("%s_%s", timestamp, header.Filename) @@ -64,9 +80,6 @@ func Upload(c *gin.Context) { return } - // 账单类型从去重结果获取 - billType := dedupResult.BillType - fmt.Printf(" 原始记录: %d 条\n", dedupResult.OriginalCount) if dedupResult.DuplicateCount > 0 { fmt.Printf(" 重复记录: %d 条(已跳过)\n", dedupResult.DuplicateCount) @@ -91,14 +104,14 @@ func Upload(c *gin.Context) { // 使用去重后的文件路径进行后续处理 processFilePath := dedupResult.DedupFilePath - // 5. 构建输出文件路径 - baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) + // 5. 构建输出文件路径:时间_type_编号 outputExt := ".csv" if req.Format == "json" { outputExt = ".json" } - outputFileName := fmt.Sprintf("%s_%s_cleaned%s", timestamp, baseName, outputExt) outputDirAbs := config.ResolvePath(config.Global.OutputDir) + fileSeq := generateFileSequence(outputDirAbs, timestamp, billType, outputExt) + outputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, billType, fileSeq, outputExt) outputPath := filepath.Join(outputDirAbs, outputFileName) // 6. 执行 Python 清洗脚本 @@ -109,7 +122,7 @@ func Upload(c *gin.Context) { End: req.End, Format: req.Format, } - cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts) + _, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts) if cleanErr != nil { c.JSON(http.StatusInternalServerError, model.UploadResponse{ Result: false, @@ -118,12 +131,7 @@ func Upload(c *gin.Context) { return } - // 7. 如果去重检测没有识别出类型,从 Python 输出中检测 - if billType == "" { - billType = cleanResult.BillType - } - - // 8. 将去重后的原始数据存入 MongoDB(原始数据集合) + // 7. 将去重后的原始数据存入 MongoDB(原始数据集合) rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp) if rawErr != nil { fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr) @@ -163,3 +171,14 @@ 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)) + if err != nil || len(matches) == 0 { + return "001" + } + return fmt.Sprintf("%03d", len(matches)+1) +} diff --git a/server/main.go b/server/main.go index d482d3b..0160452 100644 --- a/server/main.go +++ b/server/main.go @@ -2,16 +2,20 @@ package main import ( "fmt" - "net/http" "os" "os/signal" "syscall" "github.com/gin-gonic/gin" + "billai-server/adapter" + adapterHttp "billai-server/adapter/http" + "billai-server/adapter/python" "billai-server/config" "billai-server/database" - "billai-server/handler" + "billai-server/repository" + repoMongo "billai-server/repository/mongo" + "billai-server/router" ) func main() { @@ -36,7 +40,17 @@ func main() { fmt.Println(" 请在配置文件中指定正确的 Python 路径") } - // 连接 MongoDB + // 初始化适配器(外部服务交互层) + initAdapters() + + // 初始化数据层 + if err := initRepository(); err != nil { + fmt.Printf("⚠️ 警告: 数据层初始化失败: %v\n", err) + fmt.Println(" 账单数据将不会存储到数据库") + os.Exit(1) + } + + // 连接 MongoDB(保持兼容旧代码,后续可移除) if err := database.Connect(); err != nil { fmt.Printf("⚠️ 警告: MongoDB 连接失败: %v\n", err) fmt.Println(" 账单数据将不会存储到数据库") @@ -50,7 +64,10 @@ func main() { r := gin.Default() // 注册路由 - setupRoutes(r, outputDirAbs, pythonPathAbs) + router.Setup(r, router.Config{ + OutputDir: outputDirAbs, + PythonPath: pythonPathAbs, + }) // 监听系统信号 go func() { @@ -67,34 +84,18 @@ func main() { r.Run(":" + config.Global.Port) } -// setupRoutes 设置路由 -func setupRoutes(r *gin.Engine, outputDirAbs, pythonPathAbs string) { - // 健康检查 - r.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "python_path": pythonPathAbs, - }) - }) - - // API 路由 - api := r.Group("/api") - { - api.POST("/upload", handler.Upload) - api.GET("/review", handler.Review) - } - - // 静态文件下载 - r.Static("/download", outputDirAbs) -} - // printBanner 打印启动横幅 func printBanner(pythonPath, uploadDir, outputDir string) { fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println("📦 BillAI 账单分析服务") fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Printf("📁 项目根目录: %s\n", config.Global.ProjectRoot) - fmt.Printf("🐍 Python路径: %s\n", pythonPath) + fmt.Printf("� 适配器模式: %s\n", config.Global.AnalyzerMode) + if config.Global.AnalyzerMode == "http" { + fmt.Printf("🌐 分析服务: %s\n", config.Global.AnalyzerURL) + } else { + fmt.Printf("🐍 Python路径: %s\n", pythonPath) + } fmt.Printf("📂 上传目录: %s\n", uploadDir) fmt.Printf("📂 输出目录: %s\n", outputDir) fmt.Printf("🍃 MongoDB: %s/%s\n", config.Global.MongoURI, config.Global.MongoDatabase) @@ -106,8 +107,60 @@ func printAPIInfo() { fmt.Printf("\n🚀 服务已启动: http://localhost:%s\n", config.Global.Port) fmt.Println("📝 API 接口:") fmt.Println(" POST /api/upload - 上传并分析账单") + fmt.Println(" GET /api/bills - 获取账单列表(支持分页和时间筛选)") fmt.Println(" GET /api/review - 获取需要复核的记录") fmt.Println(" GET /download/* - 下载结果文件") fmt.Println(" GET /health - 健康检查") fmt.Println() } + +// initAdapters 初始化适配器(外部服务交互层) +// 在这里配置与外部系统的交互方式 +// 支持两种模式: http (推荐) 和 subprocess +func initAdapters() { + var cleaner adapter.Cleaner + + switch config.Global.AnalyzerMode { + case "http": + // 使用 HTTP API 调用 Python 服务(推荐) + httpCleaner := adapterHttp.NewCleaner(config.Global.AnalyzerURL) + + // 检查服务健康状态 + if err := httpCleaner.HealthCheck(); err != nil { + fmt.Printf("⚠️ 警告: Python 分析服务不可用 (%s): %v\n", config.Global.AnalyzerURL, err) + fmt.Println(" 请确保分析服务已启动: cd analyzer && python server.py") + } else { + fmt.Printf("🌐 已连接到分析服务: %s\n", config.Global.AnalyzerURL) + } + cleaner = httpCleaner + + case "subprocess": + // 使用子进程调用 Python 脚本(传统模式) + pythonCleaner := python.NewCleaner() + fmt.Println("🐍 使用子进程模式调用 Python") + cleaner = pythonCleaner + + default: + // 默认使用 HTTP 模式 + cleaner = adapterHttp.NewCleaner(config.Global.AnalyzerURL) + fmt.Printf("🌐 使用 HTTP 模式 (默认): %s\n", config.Global.AnalyzerURL) + } + + adapter.SetCleaner(cleaner) + fmt.Println("🔌 适配器初始化完成") +} + +// initRepository 初始化数据存储层 +// 在这里配置数据持久化方式 +// 后续可以通过修改这里来切换不同的存储实现(如 PostgreSQL、MySQL 等) +func initRepository() error { + // 初始化 MongoDB 存储 + mongoRepo := repoMongo.NewRepository() + if err := mongoRepo.Connect(); err != nil { + return err + } + repository.SetRepository(mongoRepo) + + fmt.Println("💾 数据层初始化完成") + return nil +} diff --git a/server/model/request.go b/server/model/request.go index 031562e..70dccd2 100644 --- a/server/model/request.go +++ b/server/model/request.go @@ -2,10 +2,10 @@ 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 } - diff --git a/server/repository/global.go b/server/repository/global.go new file mode 100644 index 0000000..e15410c --- /dev/null +++ b/server/repository/global.go @@ -0,0 +1,14 @@ +// Package repository 全局存储实例管理 +package repository + +var globalRepo BillRepository + +// SetRepository 设置全局存储实例 +func SetRepository(r BillRepository) { + globalRepo = r +} + +// GetRepository 获取全局存储实例 +func GetRepository() BillRepository { + return globalRepo +} diff --git a/server/repository/repository.go b/server/repository/repository.go new file mode 100644 index 0000000..429ccef --- /dev/null +++ b/server/repository/repository.go @@ -0,0 +1,44 @@ +// Package repository 定义数据存储层接口 +// 负责所有数据持久化操作的抽象 +package repository + +import "billai-server/model" + +// BillRepository 账单存储接口 +type BillRepository interface { + // Connect 建立连接 + Connect() error + + // Disconnect 断开连接 + Disconnect() error + + // SaveRawBills 保存原始账单数据 + SaveRawBills(bills []model.RawBill) (int, error) + + // SaveCleanedBills 保存清洗后的账单数据 + // 返回: 保存数量、重复数量、错误 + SaveCleanedBills(bills []model.CleanedBill) (saved int, duplicates int, err error) + + // CheckRawDuplicate 检查原始数据是否重复 + CheckRawDuplicate(fieldName, value string) (bool, error) + + // CheckCleanedDuplicate 检查清洗后数据是否重复 + CheckCleanedDuplicate(bill *model.CleanedBill) (bool, error) + + // GetCleanedBills 获取清洗后的账单列表 + GetCleanedBills(filter map[string]interface{}) ([]model.CleanedBill, error) + + // GetCleanedBillsPaged 获取清洗后的账单列表(带分页) + // 返回: 账单列表、总数、错误 + GetCleanedBillsPaged(filter map[string]interface{}, page, pageSize int) ([]model.CleanedBill, int64, error) + + // GetBillsAggregate 获取账单聚合统计(总收入、总支出) + // 返回: 总支出、总收入、错误 + GetBillsAggregate(filter map[string]interface{}) (totalExpense float64, totalIncome float64, err error) + + // GetBillsNeedReview 获取需要复核的账单 + GetBillsNeedReview() ([]model.CleanedBill, error) + + // CountRawByField 按字段统计原始数据数量 + CountRawByField(fieldName, value string) (int64, error) +} diff --git a/server/router/router.go b/server/router/router.go new file mode 100644 index 0000000..a3fdd33 --- /dev/null +++ b/server/router/router.go @@ -0,0 +1,53 @@ +// Package router 路由配置 +package router + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "billai-server/handler" +) + +// Config 路由配置参数 +type Config struct { + OutputDir string // 输出目录(用于静态文件服务) + PythonPath string // Python 路径(用于健康检查显示) +} + +// Setup 设置所有路由 +func Setup(r *gin.Engine, cfg Config) { + // 健康检查 + r.GET("/health", healthCheck(cfg.PythonPath)) + + // API 路由组 + setupAPIRoutes(r) + + // 静态文件下载 + r.Static("/download", cfg.OutputDir) +} + +// healthCheck 健康检查处理器 +func healthCheck(pythonPath string) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "python_path": pythonPath, + }) + } +} + +// setupAPIRoutes 设置 API 路由 +func setupAPIRoutes(r *gin.Engine) { + api := r.Group("/api") + { + // 账单上传 + api.POST("/upload", handler.Upload) + + // 复核相关 + api.GET("/review", handler.Review) + + // 账单查询 + api.GET("/bills", handler.ListBills) + } +} diff --git a/server/service/bill.go b/server/service/bill.go index 6d819ad..da931b0 100644 --- a/server/service/bill.go +++ b/server/service/bill.go @@ -312,7 +312,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) // 提取字段 - 订单号(用于去重判断) if idx, ok := colIdx["交易订单号"]; ok && len(row) > idx { bill.TransactionID = strings.TrimSpace(row[idx]) - } else if idx, ok := colIdx["交易号"]; ok && len(row) > idx { + } else if idx, ok := colIdx["交易单号"]; ok && len(row) > idx { bill.TransactionID = strings.TrimSpace(row[idx]) } if idx, ok := colIdx["商家订单号"]; ok && len(row) > idx { @@ -325,24 +325,34 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) } if idx, ok := colIdx["交易分类"]; ok && len(row) > idx { bill.Category = row[idx] + } else if idx, ok := colIdx["交易类型"]; ok && len(row) > idx { + bill.Category = row[idx] } if idx, ok := colIdx["交易对方"]; ok && len(row) > idx { bill.Merchant = row[idx] } if idx, ok := colIdx["商品说明"]; ok && len(row) > idx { bill.Description = row[idx] + } else if idx, ok := colIdx["商品"]; ok && len(row) > idx { + bill.Description = row[idx] } if idx, ok := colIdx["收/支"]; ok && len(row) > idx { bill.IncomeExpense = row[idx] } if idx, ok := colIdx["金额"]; ok && len(row) > idx { bill.Amount = parseAmount(row[idx]) + } else if idx, ok := colIdx["金额(元)"]; ok && len(row) > idx { + bill.Amount = parseAmount(row[idx]) } - if idx, ok := colIdx["支付方式"]; ok && len(row) > idx { + if idx, ok := colIdx["收/付款方式"]; ok && len(row) > idx { + bill.PayMethod = row[idx] + } else if idx, ok := colIdx["支付方式"]; ok && len(row) > idx { bill.PayMethod = row[idx] } if idx, ok := colIdx["交易状态"]; ok && len(row) > idx { bill.Status = row[idx] + } else if idx, ok := colIdx["当前状态"]; ok && len(row) > idx { + bill.Status = row[idx] } if idx, ok := colIdx["备注"]; ok && len(row) > idx { bill.Remark = row[idx] diff --git a/server/service/cleaner.go b/server/service/cleaner.go index 1ec265f..691e039 100644 --- a/server/service/cleaner.go +++ b/server/service/cleaner.go @@ -1,84 +1,48 @@ +// Package service 业务逻辑层 package service import ( - "fmt" - "os/exec" - "strings" - - "billai-server/config" + "billai-server/adapter" ) -// CleanOptions 清洗选项 -type CleanOptions struct { - Year string // 年份筛选 - Month string // 月份筛选 - Start string // 起始日期 - End string // 结束日期 - Format string // 输出格式: csv/json -} +// CleanOptions 清洗选项(保持向后兼容) +type CleanOptions = adapter.CleanOptions -// CleanResult 清洗结果 -type CleanResult struct { - BillType string // 检测到的账单类型: alipay/wechat - Output string // Python 脚本输出 -} +// CleanResult 清洗结果(保持向后兼容) +type CleanResult = adapter.CleanResult -// RunCleanScript 执行 Python 清洗脚本 +// RunCleanScript 执行清洗脚本(使用适配器) // inputPath: 输入文件路径 // outputPath: 输出文件路径 // opts: 清洗选项 func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) { - // 构建命令参数 - cleanScriptAbs := config.ResolvePath(config.Global.CleanScript) - args := []string{cleanScriptAbs, inputPath, outputPath} - - if opts != nil { - if opts.Year != "" { - args = append(args, "--year", opts.Year) - } - if opts.Month != "" { - args = append(args, "--month", opts.Month) - } - if opts.Start != "" { - args = append(args, "--start", opts.Start) - } - if opts.End != "" { - args = append(args, "--end", opts.End) - } - if opts.Format != "" { - args = append(args, "--format", opts.Format) - } - } - - // 执行 Python 脚本 - fmt.Printf("🐍 执行清洗脚本...\n") - pythonPathAbs := config.ResolvePath(config.Global.PythonPath) - cmd := exec.Command(pythonPathAbs, args...) - cmd.Dir = config.Global.ProjectRoot - - output, err := cmd.CombinedOutput() - outputStr := string(output) - - if err != nil { - return nil, fmt.Errorf("清洗脚本执行失败: %w\n输出: %s", err, outputStr) - } - - // 从输出中检测账单类型 - billType := DetectBillTypeFromOutput(outputStr) - - return &CleanResult{ - BillType: billType, - Output: outputStr, - }, nil + cleaner := adapter.GetCleaner() + return cleaner.Clean(inputPath, outputPath, opts) } -// DetectBillTypeFromOutput 从 Python 脚本输出中检测账单类型 +// DetectBillTypeFromOutput 从脚本输出中检测账单类型 +// 保留此函数以兼容其他调用 func DetectBillTypeFromOutput(output string) string { - if strings.Contains(output, "支付宝") { + if containsSubstring(output, "支付宝") { return "alipay" } - if strings.Contains(output, "微信") { + if containsSubstring(output, "微信") { return "wechat" } return "" } + +// containsSubstring 检查字符串是否包含子串 +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/web/.npmrc b/web/.npmrc index b6f27f1..2a118d8 100644 --- a/web/.npmrc +++ b/web/.npmrc @@ -1 +1,2 @@ engine-strict=true +registry=https://registry.npmmirror.com diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..cc51772 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,56 @@ +# SvelteKit Web 前端 Dockerfile +# 多阶段构建:构建阶段 + 运行阶段 + +# ===== 构建阶段 ===== +FROM node:20-alpine AS builder + +WORKDIR /app + +# 配置 yarn 镜像源(国内) +RUN yarn config set registry https://registry.npmmirror.com + +# 先复制依赖文件,利用 Docker 缓存 +COPY package.json yarn.lock* ./ + +# 安装依赖 +RUN yarn install --frozen-lockfile || yarn install + +# 复制源代码 +COPY . . + +# 构建生产版本 +RUN yarn build + +# ===== 运行阶段 ===== +FROM node:20-alpine + +WORKDIR /app + +# 配置 Alpine 镜像源(国内) +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +# 安装必要工具 +RUN apk --no-cache add curl + +# 设置时区 +ENV TZ=Asia/Shanghai + +# 设置为生产环境 +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 + +# 从构建阶段复制构建产物和依赖 +COPY --from=builder /app/build ./build +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/node_modules ./node_modules + +# 暴露端口 +EXPOSE 3000 + +# 健康检查 +HEALTHCHECK --interval=10s --timeout=5s --retries=5 \ + CMD curl -f http://localhost:3000 || exit 1 + +# 启动服务 +CMD ["node", "build"] diff --git a/web/package.json b/web/package.json index 9826130..b0dea2b 100644 --- a/web/package.json +++ b/web/package.json @@ -20,7 +20,7 @@ "@eslint/js": "^9.39.1", "@internationalized/date": "^3.10.0", "@lucide/svelte": "^0.561.0", - "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.4.0", "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.18", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6c4a19a..d22f371 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,11 +1,29 @@ -// API 配置 -const API_BASE = 'http://localhost:8080'; +// API 配置 - 使用相对路径,由 SvelteKit 代理到后端 +const API_BASE = ''; + +// 健康检查 +export async function checkHealth(): Promise { + try { + const response = await fetch(`${API_BASE}/health`, { + method: 'GET', + signal: AbortSignal.timeout(3000) // 3秒超时 + }); + return response.ok; + } catch { + return false; + } +} // 类型定义 +export type BillType = 'alipay' | 'wechat'; + export interface UploadData { - bill_type: 'alipay' | 'wechat'; + bill_type: BillType; file_url: string; file_name: string; + raw_count: number; + cleaned_count: number; + duplicate_count?: number; } export interface UploadResponse { @@ -52,9 +70,14 @@ export interface BillRecord { } // 上传账单 -export async function uploadBill(file: File, options?: { year?: number; month?: number }): Promise { +export async function uploadBill( + file: File, + type: BillType, + options?: { year?: number; month?: number } +): Promise { const formData = new FormData(); formData.append('file', file); + formData.append('type', type); if (options?.year) { formData.append('year', options.year.toString()); @@ -108,23 +131,23 @@ function parseCSV(text: string): BillRecord[] { const lines = text.trim().split('\n'); if (lines.length < 2) return []; - const headers = lines[0].split(','); const records: BillRecord[] = []; + // CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级 for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); - if (values.length >= headers.length) { + if (values.length >= 7) { records.push({ time: values[0] || '', category: values[1] || '', merchant: values[2] || '', - description: values[3] || '', - income_expense: values[4] || '', - amount: values[5] || '', - payment_method: values[6] || '', - status: values[7] || '', - remark: values[8] || '', - needs_review: values[9] || '', + description: values[4] || '', // 跳过 values[3] (对方账号) + income_expense: values[5] || '', + amount: values[6] || '', + payment_method: values[7] || '', + status: values[8] || '', + remark: values[11] || '', + needs_review: values[13] || '', // 复核等级在第14列 }); } } @@ -160,5 +183,71 @@ function parseCSVLine(line: string): string[] { return result; } +// 清洗后的账单记录 +export interface CleanedBill { + id: string; + bill_type: string; + time: string; + category: string; + merchant: string; + description: string; + income_expense: string; + amount: number; + pay_method: string; + status: string; + remark: string; + review_level: string; +} + +// 账单列表请求参数 +export interface FetchBillsParams { + page?: number; + page_size?: number; + start_date?: string; + end_date?: string; + category?: string; + type?: string; // 账单来源 alipay/wechat + income_expense?: string; // 收支类型 收入/支出 +} + +// 账单列表响应 +export interface BillsResponse { + result: boolean; + message?: string; + data?: { + total: number; + total_expense: number; // 筛选条件下的总支出 + total_income: number; // 筛选条件下的总收入 + page: number; + page_size: number; + pages: number; + bills: CleanedBill[]; + }; +} + +// 获取账单列表(支持分页和筛选) +export async function fetchBills(params: FetchBillsParams = {}): Promise { + const searchParams = new URLSearchParams(); + + if (params.page) searchParams.set('page', params.page.toString()); + if (params.page_size) searchParams.set('page_size', params.page_size.toString()); + if (params.start_date) searchParams.set('start_date', params.start_date); + if (params.end_date) searchParams.set('end_date', params.end_date); + if (params.category) searchParams.set('category', params.category); + if (params.type) searchParams.set('type', params.type); + if (params.income_expense) searchParams.set('income_expense', params.income_expense); + + const queryString = searchParams.toString(); + const url = `${API_BASE}/api/bills${queryString ? '?' + queryString : ''}`; + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return response.json(); +} + diff --git a/web/src/lib/components/analysis/BillRecordsTable.svelte b/web/src/lib/components/analysis/BillRecordsTable.svelte index 812104a..93b0f6f 100644 --- a/web/src/lib/components/analysis/BillRecordsTable.svelte +++ b/web/src/lib/components/analysis/BillRecordsTable.svelte @@ -1,6 +1,6 @@ + +{#snippet MonthSelect()} + { + if (!placeholder) return; + const v = Number.parseInt(e.currentTarget.value); + const newPlaceholder = placeholder.set({ month: v }); + placeholder = newPlaceholder.subtract({ months: monthIndex }); + }} + /> +{/snippet} + +{#snippet YearSelect()} + +{/snippet} + +{#if captionLayout === "dropdown"} + {@render MonthSelect()} + {@render YearSelect()} +{:else if captionLayout === "dropdown-months"} + {@render MonthSelect()} + {#if placeholder} + {formatYear(placeholder)} + {/if} +{:else if captionLayout === "dropdown-years"} + {#if placeholder} + {formatMonth(placeholder)} + {/if} + {@render YearSelect()} +{:else} + {formatMonth(month)} {formatYear(month)} +{/if} diff --git a/web/src/lib/components/ui/calendar/calendar-cell.svelte b/web/src/lib/components/ui/calendar/calendar-cell.svelte new file mode 100644 index 0000000..4cdb548 --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-day.svelte b/web/src/lib/components/ui/calendar/calendar-day.svelte new file mode 100644 index 0000000..19d7bde --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-day.svelte @@ -0,0 +1,35 @@ + + +span]:text-xs [&>span]:opacity-70", + className + )} + {...restProps} +/> diff --git a/web/src/lib/components/ui/calendar/calendar-grid-body.svelte b/web/src/lib/components/ui/calendar/calendar-grid-body.svelte new file mode 100644 index 0000000..8cd86de --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-grid-body.svelte @@ -0,0 +1,12 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-grid-head.svelte b/web/src/lib/components/ui/calendar/calendar-grid-head.svelte new file mode 100644 index 0000000..333edc4 --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-grid-head.svelte @@ -0,0 +1,12 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-grid-row.svelte b/web/src/lib/components/ui/calendar/calendar-grid-row.svelte new file mode 100644 index 0000000..9032236 --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-grid-row.svelte @@ -0,0 +1,12 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-grid.svelte b/web/src/lib/components/ui/calendar/calendar-grid.svelte new file mode 100644 index 0000000..e0c8627 --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-grid.svelte @@ -0,0 +1,16 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-head-cell.svelte b/web/src/lib/components/ui/calendar/calendar-head-cell.svelte new file mode 100644 index 0000000..131807e --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-head-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-header.svelte b/web/src/lib/components/ui/calendar/calendar-header.svelte new file mode 100644 index 0000000..c39e955 --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-header.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-heading.svelte b/web/src/lib/components/ui/calendar/calendar-heading.svelte new file mode 100644 index 0000000..a9b9810 --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-heading.svelte @@ -0,0 +1,16 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-month-select.svelte b/web/src/lib/components/ui/calendar/calendar-month-select.svelte new file mode 100644 index 0000000..8d88deb --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-month-select.svelte @@ -0,0 +1,44 @@ + + + + + {#snippet child({ props, monthItems, selectedMonthItem })} + + + {/snippet} + + diff --git a/web/src/lib/components/ui/calendar/calendar-month.svelte b/web/src/lib/components/ui/calendar/calendar-month.svelte new file mode 100644 index 0000000..e747fae --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-month.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/web/src/lib/components/ui/calendar/calendar-months.svelte b/web/src/lib/components/ui/calendar/calendar-months.svelte new file mode 100644 index 0000000..f717a9d --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-months.svelte @@ -0,0 +1,19 @@ + + +
+ {@render children?.()} +
diff --git a/web/src/lib/components/ui/calendar/calendar-nav.svelte b/web/src/lib/components/ui/calendar/calendar-nav.svelte new file mode 100644 index 0000000..27f33d7 --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-nav.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/calendar/calendar-next-button.svelte b/web/src/lib/components/ui/calendar/calendar-next-button.svelte new file mode 100644 index 0000000..5c5a78d --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-next-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/web/src/lib/components/ui/calendar/calendar-prev-button.svelte b/web/src/lib/components/ui/calendar/calendar-prev-button.svelte new file mode 100644 index 0000000..33cfd63 --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-prev-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/web/src/lib/components/ui/calendar/calendar-year-select.svelte b/web/src/lib/components/ui/calendar/calendar-year-select.svelte new file mode 100644 index 0000000..226efdf --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar-year-select.svelte @@ -0,0 +1,43 @@ + + + + + {#snippet child({ props, yearItems, selectedYearItem })} + + + {/snippet} + + diff --git a/web/src/lib/components/ui/calendar/calendar.svelte b/web/src/lib/components/ui/calendar/calendar.svelte new file mode 100644 index 0000000..29b6fff --- /dev/null +++ b/web/src/lib/components/ui/calendar/calendar.svelte @@ -0,0 +1,115 @@ + + + + + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + {#if day} + {@render day({ + day: date, + outsideMonth: !isEqualMonth(date, month.value), + })} + {:else} + + {/if} + + {/each} + + {/each} + + + + {/each} + + {/snippet} + diff --git a/web/src/lib/components/ui/calendar/index.ts b/web/src/lib/components/ui/calendar/index.ts new file mode 100644 index 0000000..f3a16d2 --- /dev/null +++ b/web/src/lib/components/ui/calendar/index.ts @@ -0,0 +1,40 @@ +import Root from "./calendar.svelte"; +import Cell from "./calendar-cell.svelte"; +import Day from "./calendar-day.svelte"; +import Grid from "./calendar-grid.svelte"; +import Header from "./calendar-header.svelte"; +import Months from "./calendar-months.svelte"; +import GridRow from "./calendar-grid-row.svelte"; +import Heading from "./calendar-heading.svelte"; +import GridBody from "./calendar-grid-body.svelte"; +import GridHead from "./calendar-grid-head.svelte"; +import HeadCell from "./calendar-head-cell.svelte"; +import NextButton from "./calendar-next-button.svelte"; +import PrevButton from "./calendar-prev-button.svelte"; +import MonthSelect from "./calendar-month-select.svelte"; +import YearSelect from "./calendar-year-select.svelte"; +import Month from "./calendar-month.svelte"; +import Nav from "./calendar-nav.svelte"; +import Caption from "./calendar-caption.svelte"; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + Nav, + Month, + YearSelect, + MonthSelect, + Caption, + // + Root as Calendar, +}; diff --git a/web/src/lib/components/ui/date-range-picker/date-range-picker.svelte b/web/src/lib/components/ui/date-range-picker/date-range-picker.svelte new file mode 100644 index 0000000..c39b70b --- /dev/null +++ b/web/src/lib/components/ui/date-range-picker/date-range-picker.svelte @@ -0,0 +1,83 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + + diff --git a/web/src/lib/components/ui/date-range-picker/index.ts b/web/src/lib/components/ui/date-range-picker/index.ts new file mode 100644 index 0000000..5e0f272 --- /dev/null +++ b/web/src/lib/components/ui/date-range-picker/index.ts @@ -0,0 +1,3 @@ +import DateRangePicker from "./date-range-picker.svelte"; + +export { DateRangePicker }; diff --git a/web/src/lib/components/ui/drawer/drawer-close.svelte b/web/src/lib/components/ui/drawer/drawer-close.svelte new file mode 100644 index 0000000..482b094 --- /dev/null +++ b/web/src/lib/components/ui/drawer/drawer-close.svelte @@ -0,0 +1,25 @@ + + +{#if isMobile.current} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} diff --git a/web/src/lib/components/ui/drawer/drawer-content.svelte b/web/src/lib/components/ui/drawer/drawer-content.svelte new file mode 100644 index 0000000..f11d1d7 --- /dev/null +++ b/web/src/lib/components/ui/drawer/drawer-content.svelte @@ -0,0 +1,33 @@ + + +{#if isMobile.current} + + +
+ {@render children?.()} +
+{:else} + + {@render children?.()} + +{/if} diff --git a/web/src/lib/components/ui/drawer/drawer-description.svelte b/web/src/lib/components/ui/drawer/drawer-description.svelte new file mode 100644 index 0000000..546a584 --- /dev/null +++ b/web/src/lib/components/ui/drawer/drawer-description.svelte @@ -0,0 +1,25 @@ + + +{#if isMobile.current} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} diff --git a/web/src/lib/components/ui/drawer/drawer-footer.svelte b/web/src/lib/components/ui/drawer/drawer-footer.svelte new file mode 100644 index 0000000..5368fab --- /dev/null +++ b/web/src/lib/components/ui/drawer/drawer-footer.svelte @@ -0,0 +1,26 @@ + + +{#if isMobile.current} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} diff --git a/web/src/lib/components/ui/drawer/drawer-header.svelte b/web/src/lib/components/ui/drawer/drawer-header.svelte new file mode 100644 index 0000000..24741f7 --- /dev/null +++ b/web/src/lib/components/ui/drawer/drawer-header.svelte @@ -0,0 +1,26 @@ + + +{#if isMobile.current} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} diff --git a/web/src/lib/components/ui/drawer/drawer-title.svelte b/web/src/lib/components/ui/drawer/drawer-title.svelte new file mode 100644 index 0000000..84bdb2c --- /dev/null +++ b/web/src/lib/components/ui/drawer/drawer-title.svelte @@ -0,0 +1,25 @@ + + +{#if isMobile.current} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} diff --git a/web/src/lib/components/ui/drawer/drawer.svelte b/web/src/lib/components/ui/drawer/drawer.svelte new file mode 100644 index 0000000..87af147 --- /dev/null +++ b/web/src/lib/components/ui/drawer/drawer.svelte @@ -0,0 +1,26 @@ + + +{#if isMobile.current} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} diff --git a/web/src/lib/components/ui/drawer/index.ts b/web/src/lib/components/ui/drawer/index.ts new file mode 100644 index 0000000..526d4a0 --- /dev/null +++ b/web/src/lib/components/ui/drawer/index.ts @@ -0,0 +1,25 @@ +import Root from './drawer.svelte'; +import Content from './drawer-content.svelte'; +import Header from './drawer-header.svelte'; +import Footer from './drawer-footer.svelte'; +import Title from './drawer-title.svelte'; +import Description from './drawer-description.svelte'; +import Close from './drawer-close.svelte'; + +export { + Root, + Content, + Header, + Footer, + Title, + Description, + Close, + // + Root as Drawer, + Content as DrawerContent, + Header as DrawerHeader, + Footer as DrawerFooter, + Title as DrawerTitle, + Description as DrawerDescription, + Close as DrawerClose +}; diff --git a/web/src/lib/components/ui/popover/index.ts b/web/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..b79d12e --- /dev/null +++ b/web/src/lib/components/ui/popover/index.ts @@ -0,0 +1,19 @@ +import Root from "./popover.svelte"; +import Close from "./popover-close.svelte"; +import Content from "./popover-content.svelte"; +import Trigger from "./popover-trigger.svelte"; +import Portal from "./popover-portal.svelte"; + +export { + Root, + Content, + Trigger, + Close, + Portal, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, + Portal as PopoverPortal, +}; diff --git a/web/src/lib/components/ui/popover/popover-close.svelte b/web/src/lib/components/ui/popover/popover-close.svelte new file mode 100644 index 0000000..c360925 --- /dev/null +++ b/web/src/lib/components/ui/popover/popover-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/components/ui/popover/popover-content.svelte b/web/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..3d79f3c --- /dev/null +++ b/web/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/web/src/lib/components/ui/popover/popover-portal.svelte b/web/src/lib/components/ui/popover/popover-portal.svelte new file mode 100644 index 0000000..dd8265f --- /dev/null +++ b/web/src/lib/components/ui/popover/popover-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/components/ui/popover/popover-trigger.svelte b/web/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..586323c --- /dev/null +++ b/web/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/web/src/lib/components/ui/popover/popover.svelte b/web/src/lib/components/ui/popover/popover.svelte new file mode 100644 index 0000000..6b1aa5f --- /dev/null +++ b/web/src/lib/components/ui/popover/popover.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/index.ts b/web/src/lib/components/ui/range-calendar/index.ts new file mode 100644 index 0000000..d2d258b --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/index.ts @@ -0,0 +1,40 @@ +import Root from "./range-calendar.svelte"; +import Cell from "./range-calendar-cell.svelte"; +import Day from "./range-calendar-day.svelte"; +import Grid from "./range-calendar-grid.svelte"; +import Header from "./range-calendar-header.svelte"; +import Months from "./range-calendar-months.svelte"; +import GridRow from "./range-calendar-grid-row.svelte"; +import Heading from "./range-calendar-heading.svelte"; +import HeadCell from "./range-calendar-head-cell.svelte"; +import NextButton from "./range-calendar-next-button.svelte"; +import PrevButton from "./range-calendar-prev-button.svelte"; +import MonthSelect from "./range-calendar-month-select.svelte"; +import YearSelect from "./range-calendar-year-select.svelte"; +import Caption from "./range-calendar-caption.svelte"; +import Nav from "./range-calendar-nav.svelte"; +import Month from "./range-calendar-month.svelte"; +import GridBody from "./range-calendar-grid-body.svelte"; +import GridHead from "./range-calendar-grid-head.svelte"; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + MonthSelect, + YearSelect, + Caption, + Nav, + Month, + // + Root as RangeCalendar, +}; diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-caption.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-caption.svelte new file mode 100644 index 0000000..944654d --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-caption.svelte @@ -0,0 +1,76 @@ + + +{#snippet MonthSelect()} + { + if (!placeholder) return; + const v = Number.parseInt(e.currentTarget.value); + const newPlaceholder = placeholder.set({ month: v }); + placeholder = newPlaceholder.subtract({ months: monthIndex }); + }} + /> +{/snippet} + +{#snippet YearSelect()} + +{/snippet} + +{#if captionLayout === "dropdown"} + {@render MonthSelect()} + {@render YearSelect()} +{:else if captionLayout === "dropdown-months"} + {@render MonthSelect()} + {#if placeholder} + {formatYear(placeholder)} + {/if} +{:else if captionLayout === "dropdown-years"} + {#if placeholder} + {formatMonth(placeholder)} + {/if} + {@render YearSelect()} +{:else} + {formatMonth(month)} {formatYear(month)} +{/if} diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-cell.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-cell.svelte new file mode 100644 index 0000000..82356aa --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-day.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-day.svelte new file mode 100644 index 0000000..756c3ab --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-day.svelte @@ -0,0 +1,39 @@ + + +span]:text-xs [&>span]:opacity-70", + className + )} + {...restProps} +/> diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte new file mode 100644 index 0000000..45a31d1 --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte new file mode 100644 index 0000000..e311625 --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte new file mode 100644 index 0000000..3286b2a --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte @@ -0,0 +1,12 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-grid.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-grid.svelte new file mode 100644 index 0000000..3d74b2f --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-grid.svelte @@ -0,0 +1,16 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte new file mode 100644 index 0000000..93a60c0 --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-header.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-header.svelte new file mode 100644 index 0000000..ec1e6ea --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-header.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-heading.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-heading.svelte new file mode 100644 index 0000000..3b3325f --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-heading.svelte @@ -0,0 +1,16 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte new file mode 100644 index 0000000..dd46c3d --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte @@ -0,0 +1,44 @@ + + + + + {#snippet child({ props, monthItems, selectedMonthItem })} + + + {/snippet} + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-month.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-month.svelte new file mode 100644 index 0000000..e747fae --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-month.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-months.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-months.svelte new file mode 100644 index 0000000..f717a9d --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-months.svelte @@ -0,0 +1,19 @@ + + +
+ {@render children?.()} +
diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-nav.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-nav.svelte new file mode 100644 index 0000000..27f33d7 --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-nav.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte new file mode 100644 index 0000000..9c74970 --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte new file mode 100644 index 0000000..f63dbc4 --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte b/web/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte new file mode 100644 index 0000000..baa950a --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte @@ -0,0 +1,43 @@ + + + + + {#snippet child({ props, yearItems, selectedYearItem })} + + + {/snippet} + + diff --git a/web/src/lib/components/ui/range-calendar/range-calendar.svelte b/web/src/lib/components/ui/range-calendar/range-calendar.svelte new file mode 100644 index 0000000..4d917a6 --- /dev/null +++ b/web/src/lib/components/ui/range-calendar/range-calendar.svelte @@ -0,0 +1,112 @@ + + + + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + + + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + {#if day} + {@render day({ + day: date, + outsideMonth: !isEqualMonth(date, month.value), + })} + {:else} + + {/if} + + {/each} + + {/each} + + + + {/each} + + {/snippet} + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 5992919..e45f506 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import '../app.css'; import { page } from '$app/stores'; import { onMount } from 'svelte'; + import { checkHealth } from '$lib/api'; import * as Sidebar from '$lib/components/ui/sidebar'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Avatar from '$lib/components/ui/avatar'; @@ -14,7 +15,6 @@ import BarChart3 from '@lucide/svelte/icons/bar-chart-3'; import Settings from '@lucide/svelte/icons/settings'; import HelpCircle from '@lucide/svelte/icons/help-circle'; - import Search from '@lucide/svelte/icons/search'; import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down'; import Wallet from '@lucide/svelte/icons/wallet'; import LogOut from '@lucide/svelte/icons/log-out'; @@ -35,16 +35,32 @@ let { children } = $props(); let themeMode = $state('system'); + let serverOnline = $state(true); + let checkingHealth = $state(true); + + async function checkServerHealth() { + checkingHealth = true; + serverOnline = await checkHealth(); + checkingHealth = false; + } onMount(() => { themeMode = loadThemeFromStorage(); applyThemeToDocument(themeMode); + // 检查服务器状态 + checkServerHealth(); + // 每 30 秒检查一次 + const healthInterval = setInterval(checkServerHealth, 30000); + // 监听系统主题变化 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = () => applyThemeToDocument(themeMode); mediaQuery.addEventListener('change', handleChange); - return () => mediaQuery.removeEventListener('change', handleChange); + return () => { + mediaQuery.removeEventListener('change', handleChange); + clearInterval(healthInterval); + }; }); function cycleTheme() { @@ -78,6 +94,18 @@ if (href === '/') return pathname === '/'; return pathname.startsWith(href); } + // 根据路径获取页面标题 + function getPageTitle(pathname: string): string { + const titles: Record = { + '/': '上传账单', + '/review': '智能复核', + '/bills': '账单管理', + '/analysis': '数据分析', + '/settings': '设置', + '/help': '帮助' + }; + return titles[pathname] || 'BillAI'; + } @@ -237,18 +265,32 @@
-
- - 搜索... -
-
-
- - - - - 服务运行中 -
+

{getPageTitle($page.url.pathname)}

+
+
+
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 7e52a9f..e1cad20 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,5 +1,5 @@ @@ -95,55 +157,52 @@
-
+

数据分析

可视化你的消费数据,洞察消费习惯

- {#if isDemo} - - 📊 示例数据 - - {/if} -
- - -
-
- - e.key === 'Enter' && loadData()} - /> -
- + +
- {#if errorMessage} + {#if errorMessage && !isDemo}
{errorMessage}
{/if} - {#if records.length > 0} + + {#if isLoading} + + + +

正在加载数据...

+
+
+ {:else if analysisRecords.length > 0} - + - +
@@ -151,7 +210,7 @@ {categoryStats} {pieChartData} totalExpense={totalStats.expense} - bind:records + records={analysisRecords} categories={sortedCategories()} /> @@ -161,7 +220,30 @@ - {:else if !isLoading} - + {:else} + + + + +

+ {#if !serverAvailable} + 服务器不可用 + {:else} + 暂无账单数据 + {/if} +

+

+ {#if !serverAvailable} + 请检查后端服务是否正常运行 + {:else} + 上传账单后可在此进行数据分析 + {/if} +

+ +
+
{/if}
diff --git a/web/src/routes/api/[...path]/+server.ts b/web/src/routes/api/[...path]/+server.ts new file mode 100644 index 0000000..76f653f --- /dev/null +++ b/web/src/routes/api/[...path]/+server.ts @@ -0,0 +1,39 @@ +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +// 服务端使用 Docker 内部地址,默认使用 localhost +const API_URL = env.API_URL || 'http://localhost:8080'; + +export const GET: RequestHandler = async ({ params, url, fetch }) => { + const path = params.path; + const queryString = url.search; + + const response = await fetch(`${API_URL}/api/${path}${queryString}`); + + return new Response(response.body, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'application/json', + }, + }); +}; + +export const POST: RequestHandler = async ({ params, request, fetch }) => { + const path = params.path; + + // 转发原始请求体 + const response = await fetch(`${API_URL}/api/${path}`, { + method: 'POST', + body: await request.arrayBuffer(), + headers: { + 'Content-Type': request.headers.get('Content-Type') || 'application/octet-stream', + }, + }); + + return new Response(response.body, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'application/json', + }, + }); +}; diff --git a/web/src/routes/bills/+page.svelte b/web/src/routes/bills/+page.svelte index dd26f73..670d8ea 100644 --- a/web/src/routes/bills/+page.svelte +++ b/web/src/routes/bills/+page.svelte @@ -1,12 +1,13 @@ @@ -74,31 +143,14 @@
-
-

账单列表

-

查看和筛选已处理的账单记录

-
- - -
-
- - e.key === 'Enter' && loadBillData()} - /> +
+
+

账单列表

+

查看和筛选已处理的账单记录

-
@@ -110,166 +162,235 @@
{/if} - {#if records.length > 0} - -
- - - 交易笔数 - - - -
{stats.total}
-

符合筛选条件的记录

-
-
+ +
+ + + 总交易笔数 + + + +
{totalRecords}
+

筛选条件下的账单总数

+
+
- - - 总支出 - - - -
- ¥{stats.expense.toFixed(2)} -
-

支出金额汇总

-
-
+ + + 总支出 + + + +
+ ¥{totalExpense.toFixed(2)} +
+

筛选条件下的支出汇总

+
+
- - - 总收入 - - - -
- ¥{stats.income.toFixed(2)} -
-

收入金额汇总

-
-
-
+ + + 总收入 + + + +
+ ¥{totalIncome.toFixed(2)} +
+

筛选条件下的收入汇总

+
+
+
- - - -
+ + + +
+
筛选条件 -
-
- - -
-
- - -
-
- -
- - -
+ {#if filterCategory || filterIncomeExpense || filterBillType || startDate || endDate} + + {/if} +
+
+
+ + { + startDate = start; + endDate = end; + applyFilters(); + }} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
- - - {#if filteredRecords.length > 0} -
- - +
+ + + {#if isLoading} +
+ +

加载中...

+
+ {:else if displayRecords.length > 0} +
+ + + + 时间 + + 分类 + + + + 金额 + + + + + {#each displayRecords as record} - 时间 - 分类 - 交易对方 - 商品说明 - 收/支 - 金额 - 支付方式 - 状态 + + {record.time} + + + + {record.category} + + + + + + ¥{record.amount.toFixed(2)} + + - - - {#each filteredRecords.slice(0, 100) as record} - - - {record.time} - - - {record.category} - - - {record.merchant} - - - {record.description || '-'} - - - - {record.income_expense} - - - - ¥{record.amount} - - - {record.payment_method || '-'} - - - - {record.status || '已完成'} - - - - {/each} - - + {/each} + + +
+ + +
+

+ 显示 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalRecords)} 条,共 {totalRecords} 条 +

+
+ +
+ {#each Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + // 计算显示的页码范围 + let start = Math.max(1, currentPage - 2); + let end = Math.min(totalPages, start + 4); + start = Math.max(1, end - 4); + return start + i; + }).filter(p => p <= totalPages) as page} + + {/each} +
+
- {#if filteredRecords.length > 100} -

- 显示前 100 条记录,共 {filteredRecords.length} 条 -

- {/if} - {:else} -
- -

没有匹配的记录

-
- {/if} - - - {:else if !isLoading} - - - -

输入文件名加载账单数据

-

上传账单后可在此查看完整记录

-
-
- {/if} +
+ {:else} +
+ +

没有找到账单记录

+

请先上传账单或调整筛选条件

+
+ {/if} +
+
diff --git a/web/src/routes/download/[...path]/+server.ts b/web/src/routes/download/[...path]/+server.ts new file mode 100644 index 0000000..a917ef7 --- /dev/null +++ b/web/src/routes/download/[...path]/+server.ts @@ -0,0 +1,19 @@ +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +// 服务端使用 Docker 内部地址 +const API_URL = env.API_URL || 'http://localhost:8080'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + const path = params.path; + + const response = await fetch(`${API_URL}/download/${path}`); + + return new Response(response.body, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'text/csv', + 'Content-Disposition': response.headers.get('Content-Disposition') || '', + }, + }); +}; diff --git a/web/svelte.config.js b/web/svelte.config.js index 1295460..7fcbc16 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ @@ -7,11 +7,21 @@ const config = { // for more information about preprocessors preprocess: vitePreprocess(), + // 忽略图表组件的无障碍警告 + onwarn: (warning, handler) => { + if (warning.code.startsWith('a11y_')) return; + handler(warning); + }, + kit: { - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() + // 使用 adapter-node 以支持 Docker 部署 + adapter: adapter({ + out: 'build' + }), + // 信任的来源(禁用 CSRF 检查) + csrf: { + trustedOrigins: ['*'] + } } }; diff --git a/web/vite.config.ts b/web/vite.config.ts index 0d67809..88629ba 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -6,6 +6,23 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [sveltekit(), tailwindcss()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + }, + '/health': { + target: 'http://localhost:8080', + changeOrigin: true + }, + '/download': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + }, + test: { expect: { requireAssertions: true }, diff --git a/web/yarn.lock b/web/yarn.lock index 07a448d..5ee6360 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -390,6 +390,46 @@ resolved "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== +"@rollup/plugin-commonjs@^28.0.1": + version "28.0.9" + resolved "https://registry.npmmirror.com/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz#b875cd1590617a40c4916d561d75761c6ca3c6d1" + integrity sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA== + dependencies: + "@rollup/pluginutils" "^5.0.1" + commondir "^1.0.1" + estree-walker "^2.0.2" + fdir "^6.2.0" + is-reference "1.2.1" + magic-string "^0.30.3" + picomatch "^4.0.2" + +"@rollup/plugin-json@^6.1.0": + version "6.1.0" + resolved "https://registry.npmmirror.com/@rollup/plugin-json/-/plugin-json-6.1.0.tgz#fbe784e29682e9bb6dee28ea75a1a83702e7b805" + integrity sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA== + dependencies: + "@rollup/pluginutils" "^5.1.0" + +"@rollup/plugin-node-resolve@^16.0.0": + version "16.0.3" + resolved "https://registry.npmmirror.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz#0988e6f2cbb13316b0f5e7213f757bc9ed44928f" + integrity sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg== + dependencies: + "@rollup/pluginutils" "^5.0.1" + "@types/resolve" "1.20.2" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.22.1" + +"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0": + version "5.3.0" + resolved "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4" + integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + "@rollup/rollup-android-arm-eabi@4.55.1": version "4.55.1" resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz#76e0fef6533b3ce313f969879e61e8f21f0eeb28" @@ -525,10 +565,15 @@ resolved "https://registry.npmmirror.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz#69c746a7c232094c117c50dedbd1279fc64887b7" integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA== -"@sveltejs/adapter-auto@^7.0.0": - version "7.0.0" - resolved "https://registry.npmmirror.com/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz#e3f257a0d1be3383f6cd0c146aed8d470b33a7fe" - integrity sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw== +"@sveltejs/adapter-node@^5.4.0": + version "5.4.0" + resolved "https://registry.npmmirror.com/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz#d013d48fb86d807f6da060d9fd026c932a1b0af2" + integrity sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ== + dependencies: + "@rollup/plugin-commonjs" "^28.0.1" + "@rollup/plugin-json" "^6.1.0" + "@rollup/plugin-node-resolve" "^16.0.0" + rollup "^4.9.5" "@sveltejs/kit@^2.49.1": version "2.49.3" @@ -730,7 +775,7 @@ resolved "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== -"@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6": +"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -747,6 +792,11 @@ dependencies: undici-types "~5.26.4" +"@types/resolve@1.20.2": + version "1.20.2" + resolved "https://registry.npmmirror.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" + integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== + "@typescript-eslint/eslint-plugin@8.52.0": version "8.52.0" resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz#9a9f1d2ee974ed77a8b1bda94e77123f697ee8b4" @@ -1050,6 +1100,11 @@ commander@7: resolved "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1267,7 +1322,7 @@ deep-is@^0.1.3: resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge@^4.3.1: +deepmerge@^4.2.2, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -1463,6 +1518,11 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" @@ -1538,6 +1598,11 @@ fsevents@~2.3.2, fsevents@~2.3.3: resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + glob-parent@^6.0.2: version "6.0.2" resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" @@ -1565,6 +1630,13 @@ has-flag@^4.0.0: resolved "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + iconv-lite@0.6: version "0.6.3" resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -1610,6 +1682,13 @@ internmap@^1.0.0: resolved "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== +is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -1622,6 +1701,18 @@ is-glob@^4.0.0, is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" +is-module@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" + integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== + +is-reference@1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" + integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== + dependencies: + "@types/estree" "*" + is-reference@^3.0.3: version "3.0.3" resolved "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz#9ef7bf9029c70a67b2152da4adf57c23d718910f" @@ -1825,7 +1916,7 @@ lz-string@^1.5.0: resolved "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== -magic-string@^0.30.11, magic-string@^0.30.21, magic-string@^0.30.5: +magic-string@^0.30.11, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.5: version "0.30.21" resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -1931,6 +2022,11 @@ path-key@^3.1.0: resolved "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + pathe@^2.0.3: version "2.0.3" resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" @@ -1941,7 +2037,7 @@ picocolors@^1.0.0, picocolors@^1.1.1: resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^4.0.3: +picomatch@^4.0.2, picomatch@^4.0.3: version "4.0.3" resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -2037,12 +2133,21 @@ resolve-from@^4.0.0: resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve@^1.22.1: + version "1.22.11" + resolved "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== + dependencies: + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + robust-predicates@^3.0.2: version "3.0.2" resolved "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== -rollup@^4.43.0: +rollup@^4.43.0, rollup@^4.9.5: version "4.55.1" resolved "https://registry.npmmirror.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144" integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A== @@ -2179,6 +2284,11 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + svelte-check@^4.3.4: version "4.3.5" resolved "https://registry.npmmirror.com/svelte-check/-/svelte-check-4.3.5.tgz#2e9e05eca63fdb5523a37c666f47614d36c11212"