Compare commits
34 Commits
53d6b32856
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 642ea2d3ef | |||
|
|
a5f1a370c7 | ||
|
|
b7399d185f | ||
|
|
5537e1234d | ||
|
|
f6437b2ada | ||
|
|
cc0623c15a | ||
|
|
cb4273fad0 | ||
|
|
99ec5ea0a4 | ||
|
|
89e1e74b76 | ||
|
|
ed0a44851d | ||
|
|
a1eebd0b3f | ||
|
|
ef34a1bb5d | ||
|
|
ab9aab7beb | ||
|
|
61d26fc971 | ||
|
|
f537b53ebd | ||
|
|
b654265d96 | ||
|
|
42171c01db | ||
|
|
279eceaa95 | ||
|
|
9e146c5ef0 | ||
|
|
3cf39b4664 | ||
|
|
7b2d6a9fbb | ||
| 6e3756b2e1 | |||
| bacbabc0a5 | |||
|
|
a97a8d6a20 | ||
| 49e3176e6b | |||
| 654989d3dd | |||
| 9abd0d964f | |||
| 871da2454c | |||
| 65ea2fa477 | |||
| c61691249f | |||
| f5afb0c135 | |||
|
|
339b8afe98 | ||
|
|
3b7c1cd82b | ||
|
|
ad6a6d44ea |
75
.gitea/workflows/deploy.yaml
Normal file
75
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
# BillAI 自动部署工作流
|
||||
# 当 master 分支有 push 时自动触发部署
|
||||
# 模式: Docker 模式 - Job 在 docker:latest 容器中执行
|
||||
|
||||
name: Deploy BillAI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: docker:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${{ vars.DEPLOY_PATH }}:${{ vars.DEPLOY_PATH }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "=== 拉取最新代码 ==="
|
||||
echo "部署路径: ${{ vars.DEPLOY_PATH }}"
|
||||
git config --global --add safe.directory ${{ vars.DEPLOY_PATH }}
|
||||
cd ${{ vars.DEPLOY_PATH }}
|
||||
git fetch origin master
|
||||
# git reset --hard origin/master
|
||||
echo "当前版本: $(git log -1 --format='%h %s')"
|
||||
|
||||
- name: Build and deploy
|
||||
run: |
|
||||
echo "=== 构建并部署服务 ==="
|
||||
cd ${{ vars.DEPLOY_PATH }}
|
||||
docker compose up -d --build --remove-orphans
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
echo "=== 清理旧镜像 ==="
|
||||
docker image prune -f
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
echo "=== 健康检查 ==="
|
||||
echo "等待服务启动..."
|
||||
sleep 15
|
||||
|
||||
# 通过 Docker 健康检查状态判断(不依赖端口暴露)
|
||||
check_container() {
|
||||
local name=$1
|
||||
local container=$2
|
||||
local status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null)
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "✓ $name 服务正常"
|
||||
return 0
|
||||
else
|
||||
echo "✗ $name 服务异常 (状态: $status)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
FAILED=0
|
||||
check_container "Web" "billai-web" || FAILED=1
|
||||
check_container "Server" "billai-server" || FAILED=1
|
||||
check_container "Analyzer" "billai-analyzer" || FAILED=1
|
||||
check_container "MongoDB" "billai-mongodb" || FAILED=1
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo "=== 部署成功 ==="
|
||||
else
|
||||
echo "=== 部署失败:部分服务异常 ==="
|
||||
docker compose ps
|
||||
exit 1
|
||||
fi
|
||||
127
AGENTS.md
Normal file
127
AGENTS.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# AGENTS.md - AI Coding Agent Guidelines
|
||||
|
||||
Guidelines for AI coding agents working on BillAI - a microservices bill analysis system.
|
||||
|
||||
## Architecture
|
||||
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend Proxy & UI)
|
||||
- `server/` - Go 1.21 + Gin + MongoDB (Main API & Data Storage)
|
||||
- `analyzer/` - Python 3.12 + FastAPI (Data Cleaning & Analysis Service)
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
### Frontend (web/)
|
||||
**Working Directory:** `/Users/clz/Projects/BillAI/web`
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server
|
||||
npm run build # Production build
|
||||
npm run check # TypeScript check (svelte-check)
|
||||
npm run lint # Prettier + ESLint
|
||||
npm run format # Format code (Prettier)
|
||||
npm run test:unit # Run all unit tests (Vitest)
|
||||
npx vitest run src/routes/+page.spec.ts # Run single test file
|
||||
npx vitest run -t "test name" # Run test by name pattern
|
||||
```
|
||||
|
||||
### Backend (server/)
|
||||
**Working Directory:** `/Users/clz/Projects/BillAI/server`
|
||||
|
||||
```bash
|
||||
go run . # Start server
|
||||
go build -o server . # Build binary
|
||||
go mod tidy # Clean dependencies
|
||||
go test ./... # Run all tests
|
||||
go test ./handler/... # Run package tests
|
||||
go test -run TestName # Run single test function
|
||||
go test -v ./handler/... # Run tests with verbose output
|
||||
```
|
||||
|
||||
### Analyzer (analyzer/)
|
||||
**Working Directory:** `/Users/clz/Projects/BillAI/analyzer`
|
||||
|
||||
```bash
|
||||
python server.py # Start FastAPI server directly
|
||||
uvicorn server:app --reload # Start with hot reload
|
||||
pytest # Run all tests
|
||||
pytest test_file.py # Run single test file
|
||||
pytest -k "test_name" # Run test by name pattern
|
||||
pip install -r requirements.txt # Install dependencies
|
||||
```
|
||||
|
||||
### Docker
|
||||
**Working Directory:** `/Users/clz/Projects/BillAI`
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build # Start/rebuild all services
|
||||
docker-compose logs -f server # Follow service logs
|
||||
docker-compose down # Stop all services
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### General
|
||||
- **Comments:** Existing comments often use Chinese for business logic explanations. Maintain this style where appropriate, but English is also acceptable for technical explanations.
|
||||
- **Conventions:** Follow existing patterns strictly. Do not introduce new frameworks or libraries without checking `package.json`/`go.mod`/`requirements.txt`.
|
||||
|
||||
### TypeScript/Svelte (web/)
|
||||
- **Formatting:** Prettier (Tabs, single quotes, no trailing commas, printWidth 100).
|
||||
- **Naming:** `PascalCase` for types/components/interfaces, `camelCase` for variables/functions.
|
||||
- **Imports:** Use `$lib` alias for internal imports.
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
```
|
||||
- **Types:** Define interfaces for data models. Use `export interface`.
|
||||
- **Error Handling:** Check `response.ok`. Throw `Error` with status for UI to catch.
|
||||
|
||||
### Go Backend (server/)
|
||||
- **Structure:** `handler` (HTTP) → `service` (Logic) → `repository` (DB) → `model` (Structs).
|
||||
- **Tags:** Use `json` (snake_case) and `form` tags. Use `omitempty` for optional fields.
|
||||
```go
|
||||
type UpdateBillRequest struct {
|
||||
Category *string `json:"category,omitempty" form:"category"`
|
||||
}
|
||||
```
|
||||
- **Error Handling:** Return `500` for DB errors, `400` for bad requests. Wrap errors with context.
|
||||
```go
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, Response{Result: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### Python Analyzer (analyzer/)
|
||||
- **Style:** PEP 8. Use `snake_case` for variables/functions.
|
||||
- **Type Hints:** Mandatory for function arguments and return types.
|
||||
- **Models:** Use `pydantic.BaseModel` for API schemas.
|
||||
```python
|
||||
class CleanRequest(BaseModel):
|
||||
input_path: str
|
||||
bill_type: Optional[str] = "auto"
|
||||
```
|
||||
- **Docstrings:** Use triple quotes. Chinese descriptions are common for API docs.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **API Flow:**
|
||||
- Frontend talks to `server` (Go) via `/api` proxy.
|
||||
- `server` handles auth, DB operations, and delegates complex file processing to `analyzer` (Python).
|
||||
- `analyzer` cleanses CSV/Excel files and returns structured JSON/CSV to `server`.
|
||||
|
||||
- **Authentication:**
|
||||
- JWT based. Token stored in frontend.
|
||||
- Header: `Authorization: Bearer <token>`.
|
||||
- Backend middleware checks token. 401 triggers logout/redirect.
|
||||
|
||||
- **File Processing:**
|
||||
- Flow: Upload (ZIP/XLSX) -> Extract/Convert (to UTF-8 CSV) -> Clean (normalize columns) -> Import to DB.
|
||||
- `analyzer` uses `openpyxl` for Excel and regex for cleaning text.
|
||||
|
||||
## Important Files
|
||||
- `web/src/lib/api.ts` - Centralized API client methods.
|
||||
- `web/src/lib/models/*.ts` - Frontend data models (should match backend JSON).
|
||||
- `server/handler/*.go` - HTTP endpoint definitions.
|
||||
- `server/repository/mongo.go` - MongoDB connection and queries.
|
||||
- `analyzer/server.py` - FastAPI entry point and routing.
|
||||
- `analyzer/cleaners/*.py` - Specific logic for Alipay/Wechat/JD bills.
|
||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -5,6 +5,114 @@
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [1.3.1] - 2026-01-26
|
||||
|
||||
### 优化
|
||||
- **版本号显示优化** - 侧边栏版本号按钮样式改进
|
||||
- 移至次级导航区域,与其他菜单项样式一致
|
||||
- 更新日志改用 Sheet 组件(右侧滑出),替代底部 Drawer
|
||||
- 统一暗色主题下的视觉效果
|
||||
|
||||
## [1.3.0] - 2026-01-26
|
||||
|
||||
### 新增
|
||||
- **京东账单支持** - 支持京东白条账单上传和清洗
|
||||
- 自动识别京东账单类型(交易流水 ZIP)
|
||||
- 解析京东白条账单 CSV 格式(含还款日期信息)
|
||||
- 京东专属分类映射配置(`config/category_jd.yaml`)
|
||||
- 支持京东外卖、京东平台商户等商户识别
|
||||
- 上传页面和账单列表页面添加"京东"选项
|
||||
- 账单来源 Badge 添加紫色京东标识
|
||||
|
||||
### 优化
|
||||
- **京东订单智能去重** - 上传京东账单时自动软删除其他来源中的京东订单
|
||||
- 识别描述中包含"京东-订单编号"的支付宝/微信账单
|
||||
- 软删除冲突记录,避免重复计入支出
|
||||
- 上传响应返回被删除的记录数
|
||||
- **分类推断复核等级优化** - 京东账单引入 LOW 复核等级
|
||||
- 商户映射成功(如"京东外卖"):无需复核
|
||||
- 原分类映射成功(如"食品酒饮"→餐饮美食):无需复核
|
||||
- 通用关键词匹配成功:**LOW 复核**(需确认推断准确性)
|
||||
- 未知分类或匹配失败:HIGH 复核
|
||||
- **京东平台商户关键词扩展** - 在通用分类配置中添加京东平台常见关键词
|
||||
- 宠物用品:小佩、米家宠物、猫砂、猫粮等
|
||||
- 数码电器:小米、延长保修、家电等
|
||||
|
||||
### 技术改进
|
||||
- 新增 `analyzer/cleaners/jd.py` 京东账单清理器
|
||||
- 新增 `analyzer/config/category_jd.yaml` 京东专属配置
|
||||
- 后端新增 `SoftDeleteJDRelatedBills()` 接口和实现
|
||||
- 前端 API 类型添加 `'jd'` 支持
|
||||
- 新增单元测试 `analyzer/test_jd_cleaner.py`(11 个测试用例)
|
||||
|
||||
### 文档
|
||||
- 更新 `TODO.md` 添加 Gitea Webhook 自动部署计划
|
||||
|
||||
## [1.2.0] - 2026-01-25
|
||||
|
||||
### 新增
|
||||
- **账单删除功能** - 支持在账单详情抽屉中删除账单(软删除)
|
||||
- 删除按钮带二次确认,防止误操作
|
||||
- 删除后数据标记为 `is_deleted`,不真正从数据库删除
|
||||
- 已删除的账单在所有查询中自动过滤
|
||||
- 账单列表和复核页面都支持删除操作
|
||||
|
||||
### 技术改进
|
||||
- 后端 MongoDB 查询方法添加软删除过滤
|
||||
- 新增 `DELETE /api/bills/:id` 接口
|
||||
- `BillDetailDrawer` 组件新增 `allowDelete` 和 `onDelete` props
|
||||
|
||||
## [1.2.1] - 2026-01-23
|
||||
|
||||
### 优化
|
||||
- **智能复核快捷确认** - 在复核列表每行添加快捷确认按钮
|
||||
- 无需打开详情页面即可确认分类正确
|
||||
- 点击确认按钮立即清除复核标记并从列表移除
|
||||
- 自动更新统计数据(总数、高优先级、低优先级计数)
|
||||
- 按钮支持加载状态显示,防止重复操作
|
||||
- 提升复核效率,支持快速批量确认
|
||||
|
||||
### 文档
|
||||
- **AGENTS.md 更新** - 精简为 150 行,专为 AI 编程助手设计
|
||||
- 核心构建/测试/lint 命令说明
|
||||
- TypeScript、Go、Python 代码风格指南
|
||||
- 关键架构模式和文件位置
|
||||
|
||||
## [1.1.0] - 2026-01-23
|
||||
|
||||
### 新增
|
||||
- **ZIP 压缩包上传** - 支持上传加密的 ZIP 压缩包(微信/支付宝导出的原始格式)
|
||||
- 支持 AES 加密的 ZIP 文件,需输入解压密码
|
||||
- 自动将 xlsx 格式转换为 csv
|
||||
- 自动将 GBK 编码转换为 UTF-8
|
||||
- 前端添加密码输入框
|
||||
|
||||
### 修复
|
||||
- **支付宝扩展格式解析** - 修复从 ZIP 解压的支付宝账单(含 24 行元数据头)无法解析的问题
|
||||
- **CSV 字段数不一致** - 修复支付宝 CSV 文件字段数不一致导致解析失败的问题
|
||||
- **中文文件名乱码** - 修复 ZIP 内 GBK 编码的中文文件名解压后乱码的问题
|
||||
|
||||
### 其他
|
||||
- 添加 `AGENTS.md` 项目开发指南文档
|
||||
|
||||
## [1.0.7] - 2026-01-16
|
||||
|
||||
### 优化
|
||||
- **认证过期策略调整** - Token 过期/无效由后端统一判断,前端不再自行解析过期时间
|
||||
- 后端新增 JWT 鉴权中间件,统一返回 401 并携带错误码
|
||||
- 前端在收到 401 后清理登录状态并跳转到登录页
|
||||
|
||||
### 重构
|
||||
- **后端数据访问层收敛** - 账单相关服务统一通过 repository 访问 MongoDB,减少多套数据层并存
|
||||
|
||||
## [1.0.6] - 2026-01-08
|
||||
|
||||
### 修复
|
||||
- **数据分析页面总支出和大盘数据错误** - 修复数据分析页面与账单管理页面总支出不一致的问题
|
||||
- 原因:后端限制 `page_size` 最大为 100,但前端请求了 10000,导致只获取到部分数据
|
||||
- 解决:数据分析页面改为使用后端返回的聚合统计(`total_expense`),而不是前端计算,且关闭后端最大100的限制
|
||||
- 现在两个页面都使用相同的后端数据库聚合统计,确保数据一致性
|
||||
|
||||
## [1.0.5] - 2026-01-08
|
||||
|
||||
### 优化
|
||||
|
||||
23
README.md
23
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -10,6 +10,8 @@
|
||||

|
||||

|
||||
|
||||
变更记录见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 📊 **账单分析** - 自动解析微信/支付宝账单,生成可视化报表
|
||||
@@ -18,7 +20,7 @@
|
||||
- 🔍 **复核修正** - 对不确定的分类进行人工复核
|
||||
- 💾 **数据持久化** - MongoDB 存储原始数据和清洗后数据,支持去重检查
|
||||
- 🐳 **一键部署** - Docker Compose 快速启动全部服务
|
||||
- 🚀 **自动部署** - Gitea Webhook 触发零停机热更新
|
||||
- <EFBFBD> **用户认证** - JWT Token 登录认证
|
||||
|
||||
## 🏗️ 系统架构
|
||||
|
||||
@@ -127,13 +129,8 @@ BillAI/
|
||||
│ │ └── wechat.py # 微信
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── webhook/ # Webhook 服务 (Go)
|
||||
│ ├── main.go # Webhook 入口
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── deploy.sh # 自动部署脚本
|
||||
├── data/ # 测试数据目录
|
||||
├── mongo/ # MongoDB 数据
|
||||
├── mock_data/ # 测试数据目录
|
||||
├── mongodata/ # MongoDB 数据持久化
|
||||
└── docker-compose.yaml # 容器编排
|
||||
```
|
||||
|
||||
@@ -273,8 +270,12 @@ python server.py
|
||||
|
||||
| 版本 | 日期 | 主要更新 |
|
||||
|------|------|----------|
|
||||
| **v1.0.5** | 2026-01-08 | 🐛 修复支付宝时间格式解析错误,修复WebHook编译错误 |
|
||||
| **v1.0.4** | 2026-01-13 | 🚀 Gitea Webhook 自动部署、零停机热更新 |
|
||||
| **v1.0.9** | 2026-01-19 | 🗑️ 移除 Webhook 自动部署功能 |
|
||||
| **v1.0.8** | 2026-01-18 | ♻️ 前端账单模型统一为 UIBill、新增账单详情弹窗组件 |
|
||||
| **v1.0.7** | 2026-01-16 | 🔐 Token 过期由后端统一判断、401 自动退登 |
|
||||
| **v1.0.6** | 2026-01-08 | 🐛 修复数据分析页面总支出和大盘数据错误 |
|
||||
| **v1.0.5** | 2026-01-08 | 🐛 修复支付宝时间格式解析错误 |
|
||||
| **v1.0.4** | 2026-01-13 | ✨ MongoDB 数据持久化、上传去重 |
|
||||
| **v1.0.3** | 2026-01-13 | ✨ DateTimePicker 组件、收支分类动态切换 |
|
||||
| **v1.0.2** | 2026-01-11 | 🐛 修复时区和金额解析问题 |
|
||||
| **v1.0.1** | 2026-01-11 | 🐛 修复复核页面显示错误 |
|
||||
|
||||
20
TODO.md
20
TODO.md
@@ -34,6 +34,26 @@
|
||||
|
||||
### 高优先级
|
||||
|
||||
- [ ] **Gitea Webhook 自动部署**
|
||||
- Webhook 服务(Go 实现)
|
||||
- 监听端口 9000,接收 Gitea POST 请求
|
||||
- HMAC-SHA256 签名验证
|
||||
- 仅处理 master/main 分支的 push 事件
|
||||
- 执行部署脚本
|
||||
- 部署脚本 (deploy.sh)
|
||||
- `git pull origin master`
|
||||
- `docker-compose up -d --build --remove-orphans`
|
||||
- 自动清理旧镜像
|
||||
- 健康检查验证
|
||||
- docker-compose 配置
|
||||
- webhook 服务定义
|
||||
- 挂载 docker.sock 和项目目录
|
||||
- 环境变量配置(WEBHOOK_SECRET)
|
||||
- Gitea 仓库配置
|
||||
- 添加 Webhook URL: `http://服务器IP:9000/webhook`
|
||||
- 设置 Secret(与服务端一致)
|
||||
- 选择 Push 事件,分支过滤 `refs/heads/master`
|
||||
|
||||
- [ ] **SSE 实时状态推送**
|
||||
- 服务器实现 `/events` SSE 端点
|
||||
- 前端使用 EventSource 接收状态
|
||||
|
||||
490
WEBHOOK_SETUP.md
490
WEBHOOK_SETUP.md
@@ -1,490 +0,0 @@
|
||||
# 自动部署配置指南
|
||||
|
||||
这个指南将帮助你配置 Gitea webhook 以实现代码推送时的自动部署。
|
||||
|
||||
## 整体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[🏠 Gitea<br/>代码仓库] -->|Push Event| B[🔗 Webhook Service<br/>Port 9000]
|
||||
B -->|验证签名| C{签名有效?}
|
||||
C -->|❌ 无效| D[拒绝请求]
|
||||
C -->|✅ 有效| E{主分支?}
|
||||
E -->|❌ 否| F[跳过部署]
|
||||
E -->|✅ 是| G[📜 Deploy Script<br/>deploy.sh]
|
||||
G --> H[📥 git pull<br/>拉取最新代码]
|
||||
H --> I[🔨 热更新部署<br/>零停机]
|
||||
I --> J[🧹 清理旧镜像]
|
||||
J --> K[🏥 健康检查]
|
||||
K --> L[✅ 部署完成]
|
||||
|
||||
style A fill:#f9f,stroke:#333
|
||||
style B fill:#bbf,stroke:#333
|
||||
style G fill:#bfb,stroke:#333
|
||||
style L fill:#9f9,stroke:#333
|
||||
```
|
||||
|
||||
## 第一步:生成 Webhook Secret
|
||||
|
||||
打开终端,运行以下命令生成安全的随机密钥:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
openssl rand -hex 32
|
||||
|
||||
# Windows PowerShell
|
||||
[System.BitConverter]::ToString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).Replace('-','').ToLower()
|
||||
```
|
||||
|
||||
示例输出:
|
||||
```
|
||||
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
```
|
||||
|
||||
## 第二步:配置 docker-compose.yaml
|
||||
|
||||
编辑 `docker-compose.yaml` 文件中的 webhook 服务配置:
|
||||
|
||||
### 设置 Secret
|
||||
|
||||
```yaml
|
||||
webhook:
|
||||
environment:
|
||||
WEBHOOK_SECRET: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" # 替换为你生成的 Secret
|
||||
```
|
||||
|
||||
### 配置仓库路径(关键)
|
||||
|
||||
**重要!** 根据你的部署方式修改挂载路径:
|
||||
|
||||
#### 如果使用本地路径部署:
|
||||
|
||||
```yaml
|
||||
webhook:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /home/user/billai:/app # 替换为你的实际路径
|
||||
```
|
||||
|
||||
#### 如果在云服务器上部署:
|
||||
|
||||
```yaml
|
||||
webhook:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/billai:/app # 替换为你的实际路径
|
||||
```
|
||||
|
||||
### 网络配置说明
|
||||
|
||||
**webhook 服务对外暴露 9000 端口**,用于接收 Gitea webhook 事件。
|
||||
|
||||
这样的好处是:
|
||||
- ✅ 支持外部 Gitea 服务器的 webhook 回调
|
||||
- ✅ 灵活的部署架构
|
||||
- ✅ 支持多种网络拓扑
|
||||
|
||||
**Webhook 访问地址:**
|
||||
|
||||
- 本地访问:`http://localhost:9000/webhook`
|
||||
- 内部网络:`http://<internal-ip>:9000/webhook`
|
||||
- 公网访问:`http://<public-ip>:9000/webhook`
|
||||
- 通过域名:`http://webhook.yourdomain.com:9000/webhook`
|
||||
|
||||
## 第三步:启动 Webhook 服务
|
||||
|
||||
```bash
|
||||
# 进入项目根目录
|
||||
cd /path/to/billai
|
||||
|
||||
# 构建并启动 webhook 服务
|
||||
docker-compose up -d webhook
|
||||
|
||||
# 验证服务是否启动
|
||||
docker-compose logs webhook
|
||||
|
||||
# 测试健康检查
|
||||
curl http://localhost:9000/health
|
||||
```
|
||||
|
||||
输出应该显示:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"time": "2026-01-13T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 第四步:配置 Gitea Webhook
|
||||
|
||||
### 4.1 进入 Gitea 仓库设置
|
||||
|
||||
1. 打开 Gitea 管理界面
|
||||
2. 找到你的 BillAI 仓库
|
||||
3. 点击 **设置 (Settings)** → **Webhooks**
|
||||
|
||||
### 4.2 添加新 Webhook
|
||||
|
||||
点击 "添加 Webhook (Add Webhook)" 按钮,选择 "Gitea"
|
||||
|
||||
### 4.3 填写 Webhook 配置
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| **目标 URL** | `http://<your-server-ip>:9000/webhook` |
|
||||
| **内容类型** | `application/json` |
|
||||
| **密钥** | `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` (你生成的 Secret) |
|
||||
| **事件** | ✓ 推送事件 (Push Events) |
|
||||
| **活跃** | ✓ 是 |
|
||||
|
||||
### 4.4 配置说明
|
||||
|
||||
#### 目标 URL
|
||||
|
||||
根据 Gitea 部署方式选择合适的 URL:
|
||||
|
||||
**情况 1:Gitea 和 webhook 都在同一台机器**
|
||||
|
||||
```
|
||||
http://localhost:9000/webhook
|
||||
```
|
||||
|
||||
**情况 2:Gitea 在不同机器,webhook 在 Docker**
|
||||
|
||||
使用 webhook 所在服务器的 IP 地址:
|
||||
```
|
||||
http://<webhook-server-ip>:9000/webhook
|
||||
```
|
||||
|
||||
**情况 3:使用域名访问**
|
||||
|
||||
```
|
||||
http://webhook.yourdomain.com:9000/webhook
|
||||
或
|
||||
https://webhook.yourdomain.com/webhook (需要配置反向代理处理 HTTPS)
|
||||
```
|
||||
|
||||
#### 防火墙配置
|
||||
|
||||
确保防火墙允许 9000 端口的入站连接:
|
||||
|
||||
```bash
|
||||
# Linux (ufw)
|
||||
sudo ufw allow 9000/tcp
|
||||
|
||||
# Linux (iptables)
|
||||
sudo iptables -A INPUT -p tcp --dport 9000 -j ACCEPT
|
||||
|
||||
# Windows Firewall (PowerShell)
|
||||
New-NetFirewallRule -DisplayName "Allow Webhook" -Direction Inbound -LocalPort 9000 -Protocol TCP -Action Allow
|
||||
```
|
||||
|
||||
### 4.5 测试 Webhook
|
||||
|
||||
在 Gitea webhook 设置页面,找到刚添加的 webhook,点击 "测试 (Test)" 按钮。
|
||||
|
||||
预期响应:
|
||||
```json
|
||||
{
|
||||
"message": "非主分支,跳过部署"
|
||||
}
|
||||
```
|
||||
|
||||
(这是因为测试会使用一个默认的测试分支)
|
||||
|
||||
## 第五步:网络配置
|
||||
|
||||
### 防火墙配置
|
||||
|
||||
在部署前,确保服务器防火墙允许以下端口:
|
||||
|
||||
```bash
|
||||
# 允许 9000 端口(webhook)
|
||||
sudo ufw allow 9000/tcp
|
||||
|
||||
# 允许 3000 端口(前端web)- 已配置
|
||||
sudo ufw allow 3000/tcp
|
||||
|
||||
# 允许 80 端口(HTTP,如果使用)
|
||||
sudo ufw allow 80/tcp
|
||||
|
||||
# 允许 443 端口(HTTPS,如果使用)
|
||||
sudo ufw allow 443/tcp
|
||||
```
|
||||
|
||||
### 可选:Nginx 反向代理配置
|
||||
|
||||
如果需要通过 HTTPS 访问 webhook,可以配置 Nginx 反向代理:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name webhook.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:9000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name webhook.yourdomain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
## 第六步:测试自动部署
|
||||
|
||||
现在你可以测试自动部署功能:
|
||||
|
||||
```bash
|
||||
# 进入仓库目录
|
||||
cd /path/to/billai
|
||||
|
||||
# 做一些改动
|
||||
echo "# 测试自动部署" >> README.md
|
||||
|
||||
# 提交并推送到主分支
|
||||
git add .
|
||||
git commit -m "test: 测试自动部署功能"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
### 监控部署过程
|
||||
|
||||
```bash
|
||||
# 查看 webhook 服务日志
|
||||
docker-compose logs -f webhook
|
||||
|
||||
# 查看部署脚本日志
|
||||
docker exec billai-webhook tail -f /tmp/billai_deploy.log
|
||||
|
||||
# 查看其他服务日志
|
||||
docker-compose logs -f server
|
||||
docker-compose logs -f web
|
||||
docker-compose logs -f analyzer
|
||||
```
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### 问题 1:Webhook 签名验证失败
|
||||
|
||||
**症状**:日志中出现 "无效的签名"
|
||||
|
||||
**解决方案**:
|
||||
- 确认 Gitea 中设置的 Secret 与 docker-compose.yaml 中的 WEBHOOK_SECRET 完全相同
|
||||
- 检查是否有多余空格或特殊字符
|
||||
|
||||
```bash
|
||||
# 在 docker-compose.yaml 中查看
|
||||
grep WEBHOOK_SECRET docker-compose.yaml
|
||||
|
||||
# 在 Gitea 中重新复制 Secret,确保没有多余空格
|
||||
```
|
||||
|
||||
### 问题 2:无法连接到 Webhook 服务
|
||||
|
||||
**症状**:Gitea webhook 测试失败
|
||||
|
||||
**解决方案**:
|
||||
1. 检查 webhook 容器是否运行:
|
||||
```bash
|
||||
docker-compose ps webhook
|
||||
```
|
||||
|
||||
2. 检查端口是否正确暴露:
|
||||
```bash
|
||||
docker-compose logs webhook | grep "listen"
|
||||
```
|
||||
|
||||
3. 检查防火墙规则:
|
||||
```bash
|
||||
# Linux
|
||||
netstat -tulpn | grep 9000
|
||||
sudo ufw status
|
||||
|
||||
# Windows
|
||||
netstat -ano | findstr 9000
|
||||
```
|
||||
|
||||
4. 测试本地连接:
|
||||
```bash
|
||||
curl http://localhost:9000/health
|
||||
```
|
||||
|
||||
5. 如果是远程连接,测试网络连通性:
|
||||
```bash
|
||||
# 从 Gitea 服务器测试
|
||||
curl http://<webhook-server-ip>:9000/health
|
||||
|
||||
netstat -ano | findstr 9000 # Windows
|
||||
```
|
||||
|
||||
4. 测试本地连接:
|
||||
|
||||
```bash
|
||||
curl http://localhost:9000/health
|
||||
```
|
||||
|
||||
### 问题 3:部署脚本无法执行
|
||||
|
||||
**症状**:日志中显示 "部署脚本执行失败"
|
||||
|
||||
**解决方案**:
|
||||
1. 检查 deploy.sh 是否存在:
|
||||
```bash
|
||||
ls -la deploy.sh
|
||||
```
|
||||
|
||||
2. 检查文件权限:
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
```
|
||||
|
||||
3. 检查容器内的环境:
|
||||
```bash
|
||||
docker exec billai-webhook bash -c "ls -la /app/deploy.sh"
|
||||
```
|
||||
|
||||
4. 查看详细错误信息:
|
||||
```bash
|
||||
docker exec billai-webhook tail -f /tmp/billai_deploy.log
|
||||
```
|
||||
|
||||
### 问题 4:Docker 操作权限不足
|
||||
|
||||
**症状**:日志中显示 "permission denied" 或 "Cannot connect to Docker daemon"
|
||||
|
||||
**解决方案**:
|
||||
1. 检查 Docker Socket 是否正确挂载:
|
||||
```yaml
|
||||
webhook:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
2. 检查 Docker Socket 权限:
|
||||
```bash
|
||||
ls -l /var/run/docker.sock
|
||||
chmod 666 /var/run/docker.sock # 可能需要 sudo
|
||||
```
|
||||
|
||||
3. 确保 webhook 容器以正确的用户运行(通常是 root)
|
||||
|
||||
### 问题 5:部署后服务无法启动
|
||||
|
||||
**症状**:健康检查超时,部署失败
|
||||
|
||||
**解决方案**:
|
||||
1. 检查 docker-compose build 是否成功:
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
2. 检查依赖服务是否启动:
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
3. 查看各服务的详细日志:
|
||||
```bash
|
||||
docker-compose logs server
|
||||
docker-compose logs web
|
||||
docker-compose logs analyzer
|
||||
```
|
||||
|
||||
4. 增加健康检查的超时时间(修改 deploy.sh 中的 sleep 时间)
|
||||
|
||||
## 监控和维护
|
||||
|
||||
### 定期检查 Webhook 状态
|
||||
|
||||
```bash
|
||||
# 查看最近 50 条日志
|
||||
docker-compose logs --tail=50 webhook
|
||||
|
||||
# 实时监控日志
|
||||
docker-compose logs -f webhook
|
||||
```
|
||||
|
||||
### 查看部署历史
|
||||
|
||||
部署脚本的日志保存在:`/tmp/billai_deploy.log`
|
||||
|
||||
```bash
|
||||
# 查看部署历史
|
||||
docker exec billai-webhook cat /tmp/billai_deploy.log
|
||||
|
||||
# 持续监控
|
||||
docker exec billai-webhook tail -f /tmp/billai_deploy.log
|
||||
```
|
||||
|
||||
### 禁用/启用自动部署
|
||||
|
||||
如果需要临时禁用自动部署,修改 deploy.sh:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# 临时禁用部署,取消下面的注释
|
||||
# exit 0
|
||||
|
||||
# ... 后续脚本内容
|
||||
```
|
||||
|
||||
然后重新启动 webhook 服务:
|
||||
```bash
|
||||
docker-compose restart webhook
|
||||
```
|
||||
|
||||
## 高级配置
|
||||
|
||||
### 只部署特定分支
|
||||
|
||||
编辑 webhook/main.go,修改分支检查逻辑:
|
||||
|
||||
```go
|
||||
// 检查分支,修改条件为只部署 master 和 develop 分支
|
||||
if !strings.Contains(payload.Ref, "master") &&
|
||||
!strings.Contains(payload.Ref, "develop") {
|
||||
// 跳过部署
|
||||
}
|
||||
```
|
||||
|
||||
### 添加 Slack 通知
|
||||
|
||||
修改 deploy.sh,在部署完成后发送 Slack 消息:
|
||||
|
||||
```bash
|
||||
# 在部署完成后添加
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data "{\"text\":\"BillAI 部署完成!\"}" \
|
||||
YOUR_SLACK_WEBHOOK_URL
|
||||
```
|
||||
|
||||
### 限制部署频率
|
||||
|
||||
在 webhook/main.go 中添加速率限制,防止频繁部署。
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **使用强密钥**:确保 WEBHOOK_SECRET 是随机生成的强密钥
|
||||
2. **限制网络访问**:只允许 Gitea 服务器的 IP 访问 webhook
|
||||
3. **HTTPS**:在生产环境中使用 HTTPS 而不是 HTTP
|
||||
4. **日志监控**:定期检查部署日志,及时发现问题
|
||||
5. **备份**:在部署前备份重要数据
|
||||
|
||||
## 相关文件
|
||||
|
||||
- [webhook/README.md](../webhook/README.md) - Webhook 服务文档
|
||||
- [deploy.sh](../deploy.sh) - 部署脚本
|
||||
- [docker-compose.yaml](../docker-compose.yaml) - Docker Compose 配置
|
||||
- [webhook/main.go](../webhook/main.go) - Webhook 服务源代码
|
||||
13
analyzer/.dockerignore
Normal file
13
analyzer/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
40
analyzer/analyze_jd_bills.py
Normal file
40
analyzer/analyze_jd_bills.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""分析京东账单数据"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
with open('../jd_bills.json', 'r', encoding='utf-8') as f:
|
||||
d = json.load(f)
|
||||
|
||||
bills = [b for b in d['data']['bills'] if b['bill_type'] == 'jd']
|
||||
print(f'Total JD bills: {len(bills)}')
|
||||
print()
|
||||
|
||||
# Review level distribution
|
||||
review_levels = {}
|
||||
for b in bills:
|
||||
lvl = b['review_level'] or 'NONE'
|
||||
review_levels[lvl] = review_levels.get(lvl, 0) + 1
|
||||
print('Review level distribution:')
|
||||
for lvl, cnt in sorted(review_levels.items()):
|
||||
print(f' {lvl}: {cnt}')
|
||||
print()
|
||||
|
||||
# Category distribution
|
||||
categories = {}
|
||||
for b in bills:
|
||||
cat = b['category']
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
print('Category distribution:')
|
||||
for cat, cnt in sorted(categories.items(), key=lambda x: -x[1]):
|
||||
print(f' {cat}: {cnt}')
|
||||
print()
|
||||
|
||||
# Show bills that need review
|
||||
print('Bills needing review:')
|
||||
print(f"{'Level':<5} | {'Category':<12} | {'Merchant':<20} | Description")
|
||||
print('-' * 70)
|
||||
for b in bills:
|
||||
if b['review_level']:
|
||||
print(f"{b['review_level']:<5} | {b['category']:<12} | {b['merchant'][:20]:<20} | {b['description'][:30]}")
|
||||
@@ -4,6 +4,7 @@
|
||||
from .base import BaseCleaner
|
||||
from .alipay import AlipayCleaner
|
||||
from .wechat import WechatCleaner
|
||||
from .jd import JDCleaner
|
||||
|
||||
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner']
|
||||
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner', 'JDCleaner']
|
||||
|
||||
|
||||
@@ -18,11 +18,31 @@ class AlipayCleaner(BaseCleaner):
|
||||
"""执行清理"""
|
||||
self.print_header()
|
||||
|
||||
# 读取数据
|
||||
# 读取数据,跳过支付宝导出文件的头部信息
|
||||
with open(self.input_file, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
header = next(reader)
|
||||
rows = list(reader)
|
||||
header = None
|
||||
rows = []
|
||||
|
||||
for row in reader:
|
||||
# 跳过空行
|
||||
if not row or not row[0].strip():
|
||||
continue
|
||||
|
||||
# 查找实际的CSV头部行(包含"交易时间"和"交易分类")
|
||||
if header is None:
|
||||
if len(row) >= 2 and "交易时间" in row[0] and "交易分类" in row[1]:
|
||||
header = row
|
||||
continue
|
||||
# 跳过头部信息行
|
||||
continue
|
||||
|
||||
# 收集数据行
|
||||
rows.append(row)
|
||||
|
||||
# 确保找到了有效的头部
|
||||
if header is None:
|
||||
raise ValueError("无法找到有效的支付宝账单表头(需包含'交易时间'和'交易分类'列)")
|
||||
|
||||
self.stats["original_count"] = len(rows)
|
||||
print(f"原始数据行数: {len(rows)}")
|
||||
|
||||
370
analyzer/cleaners/jd.py
Normal file
370
analyzer/cleaners/jd.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
京东白条账单清理模块
|
||||
"""
|
||||
import csv
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .base import (
|
||||
BaseCleaner, parse_amount, format_amount,
|
||||
is_in_date_range, create_arg_parser
|
||||
)
|
||||
from category import infer_category
|
||||
|
||||
|
||||
# 加载京东专属分类配置
|
||||
JD_CONFIG_FILE = Path(__file__).parent.parent / "config" / "category_jd.yaml"
|
||||
|
||||
def load_jd_config():
|
||||
"""加载京东分类配置"""
|
||||
with open(JD_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
_jd_config = load_jd_config()
|
||||
|
||||
|
||||
def infer_jd_category(merchant: str, product: str, original_category: str) -> tuple[str, bool, int]:
|
||||
"""
|
||||
根据京东账单的商户名称、商品说明和原分类推断统一分类
|
||||
|
||||
Args:
|
||||
merchant: 商户名称(如"京东外卖"、"京东平台商户")
|
||||
product: 交易说明/商品说明
|
||||
original_category: 京东原始分类(如"食品酒饮"、"数码电器")
|
||||
|
||||
Returns:
|
||||
(分类名称, 是否确定, 复核等级)
|
||||
|
||||
复核等级:
|
||||
0 = 无需复核(商户映射或原分类映射成功,高置信度)
|
||||
1 = 低优先级复核(通用关键词匹配成功,需确认)
|
||||
2 = 高优先级复核(全部匹配失败或未知分类,需人工分类)
|
||||
"""
|
||||
# 1. 先检查商户名称直接映射(如"京东外卖" -> "餐饮美食")
|
||||
merchant_mapping = _jd_config.get("商户映射", {})
|
||||
for merchant_key, category in merchant_mapping.items():
|
||||
if merchant_key in merchant:
|
||||
return category, True, 0 # 商户映射,无需复核
|
||||
|
||||
# 2. 尝试直接映射京东原分类
|
||||
category_mapping = _jd_config.get("分类映射", {})
|
||||
|
||||
# 处理多分类情况(如"食品酒饮 其他网购")
|
||||
original_cats = original_category.split() if original_category else []
|
||||
for orig_cat in original_cats:
|
||||
if orig_cat in category_mapping:
|
||||
mapped = category_mapping[orig_cat]
|
||||
if mapped: # 非空映射 → 使用映射结果
|
||||
return mapped, True, 0 # 原分类映射,无需复核
|
||||
# 空映射(如"其他"→"")→ 继续检查下一个原分类或进入关键词匹配
|
||||
else:
|
||||
# 未知分类(不在映射表中)→ 保留原分类,HIGH 复核
|
||||
return orig_cat, True, 2
|
||||
|
||||
# 3. 使用通用分类推断(已包含京东平台商户关键词)
|
||||
category, is_certain = infer_category(merchant, product, "支出")
|
||||
if is_certain:
|
||||
return category, True, 1 # 关键词匹配,低优先级复核
|
||||
|
||||
# 4. 返回默认分类
|
||||
return _jd_config.get("默认分类", "其他支出"), False, 2 # 全部失败,高优先级复核
|
||||
|
||||
|
||||
# 与支付宝/微信对齐的表头(包含"复核等级"字段)
|
||||
ALIGNED_HEADER = [
|
||||
"交易时间", "交易分类", "交易对方", "对方账号", "商品说明",
|
||||
"收/支", "金额", "收/付款方式", "交易状态", "交易订单号", "商家订单号", "备注", "复核等级"
|
||||
]
|
||||
|
||||
|
||||
class JDCleaner(BaseCleaner):
|
||||
"""京东白条账单清理器"""
|
||||
|
||||
def clean(self) -> None:
|
||||
"""执行清理"""
|
||||
self.print_header()
|
||||
|
||||
# 读取数据,跳过京东导出文件的头部信息
|
||||
with open(self.input_file, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
header = None
|
||||
rows = []
|
||||
|
||||
for row in reader:
|
||||
# 跳过空行
|
||||
if not row or not row[0].strip():
|
||||
continue
|
||||
|
||||
# 清理每个字段的 tab 字符
|
||||
row = [cell.strip().replace('\t', '') for cell in row]
|
||||
|
||||
# 查找实际的CSV头部行(包含"交易时间"和"商户名称")
|
||||
if header is None:
|
||||
if len(row) >= 2 and "交易时间" in row[0] and "商户名称" in row[1]:
|
||||
header = row
|
||||
continue
|
||||
# 跳过头部信息行
|
||||
continue
|
||||
|
||||
# 收集数据行
|
||||
rows.append(row)
|
||||
|
||||
# 确保找到了有效的头部
|
||||
if header is None:
|
||||
raise ValueError("无法找到有效的京东账单表头(需包含'交易时间'和'商户名称'列)")
|
||||
|
||||
self.stats["original_count"] = len(rows)
|
||||
print(f"原始数据行数: {len(rows)}")
|
||||
|
||||
# 第一步:按日期范围筛选
|
||||
rows_filtered = [
|
||||
row for row in rows
|
||||
if row and is_in_date_range(row[0], self.start_date, self.end_date)
|
||||
]
|
||||
self.stats["filtered_count"] = len(rows_filtered)
|
||||
|
||||
date_desc = f"{self.start_date} ~ {self.end_date}" if self.start_date or self.end_date else "全部"
|
||||
print(f"筛选后数据行数: {len(rows_filtered)} ({date_desc})")
|
||||
|
||||
# 第二步:分离退款和支出条目(过滤掉"不计收支")
|
||||
refund_rows = []
|
||||
expense_rows = []
|
||||
skipped_count = 0 # 不计收支(还款、冻结等)
|
||||
|
||||
for row in rows_filtered:
|
||||
if len(row) < 7:
|
||||
continue
|
||||
|
||||
income_expense = row[6].strip() # 收/支 列
|
||||
transaction_desc = row[2].strip() # 交易说明
|
||||
status = row[5].strip() # 交易状态
|
||||
|
||||
# 过滤掉"不计收支"记录(还款、冻结、预授权等)
|
||||
if income_expense == "不计收支":
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 退款判断:交易说明以"退款-"开头 或 状态包含"退款成功"
|
||||
if transaction_desc.startswith("退款-") or "退款" in status:
|
||||
refund_rows.append(row)
|
||||
elif income_expense == "支出":
|
||||
expense_rows.append(row)
|
||||
|
||||
print(f"退款条目数: {len(refund_rows)}")
|
||||
print(f"支出条目数: {len(expense_rows)}")
|
||||
print(f"不计收支过滤: {skipped_count} 条(还款/冻结等)")
|
||||
|
||||
# 第三步:处理退款
|
||||
# 京东账单特点:已全额退款的记录金额会显示为 "179.00(已全额退款)"
|
||||
final_expense_rows = self._process_expenses(expense_rows, refund_rows)
|
||||
|
||||
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)}")
|
||||
|
||||
# 第四步:转换为对齐格式并重新分类
|
||||
aligned_rows = [self._convert_and_reclassify(row_data) for row_data in final_expense_rows]
|
||||
|
||||
# 按时间排序(最新在前)
|
||||
aligned_rows.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# 统计复核数量
|
||||
review_high_count = sum(1 for row in aligned_rows if row[-1] == "HIGH")
|
||||
|
||||
self.stats["final_count"] = len(aligned_rows)
|
||||
if review_high_count > 0:
|
||||
print(f" 高优先级复核: {review_high_count} 条(无法判断)")
|
||||
|
||||
# 写入文件
|
||||
self.write_output(ALIGNED_HEADER, aligned_rows)
|
||||
|
||||
print(f"\n清理后的数据已保存到: {self.output_file}")
|
||||
|
||||
# 统计支出
|
||||
self._print_expense_summary(aligned_rows)
|
||||
|
||||
def _parse_jd_amount(self, amount_str: str) -> tuple[Decimal, bool]:
|
||||
"""
|
||||
解析京东账单金额
|
||||
|
||||
京东金额格式特点:
|
||||
- 普通金额: "179.00"
|
||||
- 全额退款: "179.00(已全额退款)"
|
||||
|
||||
Returns:
|
||||
(金额, 是否已全额退款)
|
||||
"""
|
||||
amount_str = amount_str.strip()
|
||||
|
||||
# 检查是否包含"已全额退款"
|
||||
if "(已全额退款)" in amount_str or "(已全额退款)" in amount_str:
|
||||
# 提取金额部分
|
||||
amount_part = re.sub(r'[((]已全额退款[))]', '', amount_str)
|
||||
return parse_amount(amount_part), True
|
||||
|
||||
return parse_amount(amount_str), False
|
||||
|
||||
def _process_expenses(self, expense_rows: list, refund_rows: list) -> list:
|
||||
"""
|
||||
处理支出记录
|
||||
|
||||
京东账单特点:
|
||||
1. 已全额退款的记录金额显示为 "金额(已全额退款)"
|
||||
2. 部分退款可能有单独的退款记录
|
||||
"""
|
||||
# 构建退款索引(按订单号)
|
||||
order_refunds = {}
|
||||
for row in refund_rows:
|
||||
if len(row) >= 9:
|
||||
order_no = row[8].strip() # 交易订单号
|
||||
amount = parse_amount(row[3]) # 金额
|
||||
if order_no:
|
||||
if order_no not in order_refunds:
|
||||
order_refunds[order_no] = Decimal("0")
|
||||
order_refunds[order_no] += amount
|
||||
print(f" 退款记录: {row[0]} | {row[1]} | {amount}元")
|
||||
|
||||
final_rows = []
|
||||
|
||||
for row in expense_rows:
|
||||
if len(row) < 9:
|
||||
continue
|
||||
|
||||
order_no = row[8].strip() # 交易订单号
|
||||
amount, is_fully_refunded = self._parse_jd_amount(row[3])
|
||||
|
||||
# 情况1:金额已标注"已全额退款"
|
||||
if is_fully_refunded:
|
||||
self.stats["fully_refunded"] += 1
|
||||
desc = row[2][:25] if len(row[2]) > 25 else row[2]
|
||||
print(f" 全额退款删除: {row[0]} | {row[1]} | {desc}... | {row[3]}")
|
||||
continue
|
||||
|
||||
# 情况2:检查是否有对应的退款记录
|
||||
refund_amount = order_refunds.get(order_no, Decimal("0"))
|
||||
if refund_amount > 0:
|
||||
if refund_amount >= amount:
|
||||
# 全额退款
|
||||
self.stats["fully_refunded"] += 1
|
||||
desc = row[2][:25] if len(row[2]) > 25 else row[2]
|
||||
print(f" 全额退款删除: {row[0]} | {row[1]} | {desc}... | 原{amount}元")
|
||||
else:
|
||||
# 部分退款
|
||||
remaining = amount - refund_amount
|
||||
new_row = row.copy()
|
||||
new_row[3] = format_amount(remaining)
|
||||
remark = f"原金额{amount}元,退款{refund_amount}元"
|
||||
|
||||
final_rows.append((new_row, remark))
|
||||
self.stats["partially_refunded"] += 1
|
||||
print(f" 部分退款: {row[0]} | {row[1]} | 原{amount}元 -> {format_amount(remaining)}元")
|
||||
else:
|
||||
# 无退款,正常记录
|
||||
if amount > 0:
|
||||
final_rows.append((row, None))
|
||||
else:
|
||||
self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1
|
||||
|
||||
return final_rows
|
||||
|
||||
def _convert_and_reclassify(self, row_tuple: tuple) -> list:
|
||||
"""
|
||||
转换为对齐格式并重新分类
|
||||
|
||||
京东原始字段:
|
||||
0: 交易时间, 1: 商户名称, 2: 交易说明, 3: 金额,
|
||||
4: 收/付款方式, 5: 交易状态, 6: 收/支, 7: 交易分类,
|
||||
8: 交易订单号, 9: 商家订单号, 10: 备注
|
||||
|
||||
对齐后字段:
|
||||
交易时间, 交易分类, 交易对方, 对方账号, 商品说明,
|
||||
收/支, 金额, 收/付款方式, 交易状态, 交易订单号, 商家订单号, 备注, 复核等级
|
||||
"""
|
||||
if isinstance(row_tuple, tuple):
|
||||
row, remark = row_tuple
|
||||
else:
|
||||
row, remark = row_tuple, None
|
||||
|
||||
transaction_time = row[0]
|
||||
merchant = row[1] # 商户名称
|
||||
product = row[2] # 交易说明
|
||||
amount, _ = self._parse_jd_amount(row[3])
|
||||
payment_method = row[4] if len(row) > 4 else ""
|
||||
status = row[5] if len(row) > 5 else ""
|
||||
income_expense = row[6] if len(row) > 6 else "支出"
|
||||
original_category = row[7] if len(row) > 7 else ""
|
||||
order_no = row[8] if len(row) > 8 else ""
|
||||
merchant_order_no = row[9] if len(row) > 9 else ""
|
||||
final_remark = remark if remark else (row[10] if len(row) > 10 else "/")
|
||||
|
||||
# 使用京东专属分类推断
|
||||
category, is_certain, review_level = infer_jd_category(merchant, product, original_category)
|
||||
|
||||
# 复核等级映射: 0=空, 1=LOW, 2=HIGH
|
||||
review_marks = {0: "", 1: "LOW", 2: "HIGH"}
|
||||
review_mark = review_marks.get(review_level, "")
|
||||
|
||||
return [
|
||||
transaction_time,
|
||||
category,
|
||||
merchant,
|
||||
"/", # 对方账号(京东无此字段)
|
||||
product,
|
||||
income_expense,
|
||||
format_amount(amount),
|
||||
payment_method,
|
||||
status,
|
||||
order_no,
|
||||
merchant_order_no,
|
||||
final_remark,
|
||||
review_mark
|
||||
]
|
||||
|
||||
def reclassify(self, rows: list) -> list:
|
||||
"""
|
||||
重新分类京东账单
|
||||
|
||||
京东账单在 _convert_and_reclassify 中已完成分类
|
||||
此方法为接口兼容保留
|
||||
"""
|
||||
return rows
|
||||
|
||||
def _print_expense_summary(self, expense_rows: list):
|
||||
"""打印支出统计"""
|
||||
total = Decimal("0")
|
||||
categories = {}
|
||||
|
||||
for row in expense_rows:
|
||||
if row[5] == "支出":
|
||||
amt = Decimal(row[6])
|
||||
total += amt
|
||||
cat = row[1]
|
||||
categories[cat] = categories.get(cat, Decimal("0")) + amt
|
||||
|
||||
print(f"清理后支出总额: ¥{total}")
|
||||
print("\n=== 按分类统计 ===")
|
||||
for cat, amt in sorted(categories.items(), key=lambda x: -x[1]):
|
||||
print(f" {cat}: ¥{amt}")
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行入口"""
|
||||
parser = create_arg_parser("清理京东白条账单数据")
|
||||
args = parser.parse_args()
|
||||
|
||||
from .base import compute_date_range
|
||||
|
||||
cleaner = JDCleaner(args.input_file, args.output_file)
|
||||
start_date, end_date = compute_date_range(args)
|
||||
cleaner.set_date_range(start_date, end_date)
|
||||
cleaner.clean()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -84,6 +84,40 @@
|
||||
- 供暖
|
||||
- 暖气
|
||||
|
||||
# 宠物用品
|
||||
宠物用品:
|
||||
- 宠物
|
||||
- 猫咪
|
||||
- 狗
|
||||
- 猫粮
|
||||
- 狗粮
|
||||
- 猫砂
|
||||
- 喂水
|
||||
- 猫零食
|
||||
- 犬猫
|
||||
|
||||
# 数码电器
|
||||
数码电器:
|
||||
- 饮水机
|
||||
- 净水
|
||||
- 制冰
|
||||
- nas
|
||||
- 存储
|
||||
- 硬盘
|
||||
- 电脑
|
||||
- 手机
|
||||
- 平板
|
||||
- 电器
|
||||
- 小家电
|
||||
- 充电
|
||||
- 数据线
|
||||
- 路由器
|
||||
- 音箱
|
||||
- 耳机
|
||||
- 键盘
|
||||
- 鼠标
|
||||
- 显示器
|
||||
|
||||
# 运动健身
|
||||
运动健身:
|
||||
- 健身
|
||||
@@ -113,6 +147,9 @@
|
||||
- 电影
|
||||
- 游戏
|
||||
- 娱乐
|
||||
- 书
|
||||
- 图书
|
||||
- 文娱
|
||||
- 旅游
|
||||
- 景区
|
||||
- 门票
|
||||
@@ -157,6 +194,15 @@
|
||||
- 妍丽 # AFIONA妍丽美妆店
|
||||
- 屈臣氏
|
||||
- 丝芙兰
|
||||
- 保鲜盒
|
||||
- 收纳
|
||||
- 厨房
|
||||
- 清洁
|
||||
- 洗衣
|
||||
- 纸巾
|
||||
- 毛巾
|
||||
- 床品
|
||||
- 家居
|
||||
|
||||
# 餐饮美食
|
||||
餐饮美食:
|
||||
|
||||
48
analyzer/config/category_jd.yaml
Normal file
48
analyzer/config/category_jd.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
# =============================================================================
|
||||
# 京东账单分类映射配置
|
||||
# 将京东原始分类转换为统一分类
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# 京东原始分类 -> 统一分类映射
|
||||
# 京东账单中的"交易分类"字段可能包含以下值:
|
||||
# - 余额、小金库、白条:财务操作(已在清洗时过滤)
|
||||
# - 其他、其他网购、网购:需要根据商品说明进一步判断
|
||||
# - 食品酒饮:餐饮美食
|
||||
# - 数码电器、电脑办公:数码电器
|
||||
# - 日用百货:日用百货
|
||||
# - 图书文娱:文化休闲
|
||||
# =============================================================================
|
||||
|
||||
分类映射:
|
||||
# 直接映射(京东分类 -> 统一分类)
|
||||
食品酒饮: 餐饮美食
|
||||
数码电器: 数码电器
|
||||
电脑办公: 数码电器
|
||||
日用百货: 日用百货
|
||||
图书文娱: 文化休闲
|
||||
|
||||
# 需要进一步判断的分类(返回空字符串,由关键词推断)
|
||||
其他: ""
|
||||
其他网购: ""
|
||||
网购: ""
|
||||
|
||||
# 财务类(通常已被过滤,但以防万一)
|
||||
余额: ""
|
||||
小金库: ""
|
||||
白条: ""
|
||||
|
||||
# =============================================================================
|
||||
# 商户名称 -> 统一分类映射
|
||||
# 根据商户名称直接映射分类,无需关键词匹配
|
||||
# =============================================================================
|
||||
|
||||
商户映射:
|
||||
京东外卖: 餐饮美食
|
||||
|
||||
# =============================================================================
|
||||
# 默认分类
|
||||
# 当无法匹配任何规则时使用
|
||||
# =============================================================================
|
||||
|
||||
默认分类: 其他支出
|
||||
196
analyzer/converter.py
Normal file
196
analyzer/converter.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
账单文件格式转换模块
|
||||
|
||||
支持:
|
||||
- xlsx -> csv 转换
|
||||
- GBK/GB2312 -> UTF-8 编码转换
|
||||
- 账单类型自动检测
|
||||
"""
|
||||
import os
|
||||
import csv
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# 尝试导入 openpyxl,用于读取 xlsx 文件
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
HAS_OPENPYXL = True
|
||||
except ImportError:
|
||||
HAS_OPENPYXL = False
|
||||
|
||||
|
||||
def detect_encoding(filepath: str) -> str:
|
||||
"""
|
||||
检测文件编码
|
||||
|
||||
Returns:
|
||||
'utf-8', 'gbk', 或 'utf-8-sig'
|
||||
"""
|
||||
# 尝试读取前几行来检测编码
|
||||
encodings = ['utf-8', 'utf-8-sig', 'gbk', 'gb2312', 'gb18030']
|
||||
|
||||
for encoding in encodings:
|
||||
try:
|
||||
with open(filepath, 'r', encoding=encoding) as f:
|
||||
# 尝试读取前 10 行
|
||||
for _ in range(10):
|
||||
f.readline()
|
||||
return encoding
|
||||
except (UnicodeDecodeError, UnicodeError):
|
||||
continue
|
||||
|
||||
# 默认使用 gbk
|
||||
return 'gbk'
|
||||
|
||||
|
||||
def detect_bill_type_from_content(content: str, filename: str = "") -> str:
|
||||
"""
|
||||
从内容和文件名检测账单类型
|
||||
|
||||
Returns:
|
||||
'alipay', 'wechat', 'jd', 或 ''
|
||||
"""
|
||||
# 从文件名检测
|
||||
filename_lower = filename.lower()
|
||||
if '支付宝' in filename or 'alipay' in filename_lower:
|
||||
return 'alipay'
|
||||
if '微信' in filename or 'wechat' in filename_lower:
|
||||
return 'wechat'
|
||||
if '京东' in filename or 'jd' in filename_lower:
|
||||
return 'jd'
|
||||
|
||||
# 从内容检测
|
||||
# 支付宝特征: 有 "交易分类" 和 "对方账号" 列
|
||||
if '交易分类' in content and '对方账号' in content:
|
||||
return 'alipay'
|
||||
|
||||
# 微信特征: 有 "交易类型" 和 "金额(元)" 列
|
||||
if '交易类型' in content and '金额(元)' in content:
|
||||
return 'wechat'
|
||||
|
||||
# 京东特征: 有 "商户名称" 和 "交易说明" 列,或头部包含 "京东账号名"
|
||||
if '商户名称' in content and '交易说明' in content:
|
||||
return 'jd'
|
||||
if '京东账号名' in content:
|
||||
return 'jd'
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def convert_xlsx_to_csv(xlsx_path: str, csv_path: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
将 xlsx 文件转换为 csv 文件
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
if not HAS_OPENPYXL:
|
||||
return False, "缺少 openpyxl 库,无法读取 xlsx 文件。请运行: pip install openpyxl"
|
||||
|
||||
try:
|
||||
wb = load_workbook(xlsx_path, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
|
||||
with open(csv_path, 'w', encoding='utf-8', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
# 跳过全空行
|
||||
if all(cell is None for cell in row):
|
||||
continue
|
||||
# 将 None 转换为空字符串
|
||||
writer.writerow(['' if cell is None else str(cell) for cell in row])
|
||||
|
||||
wb.close()
|
||||
return True, "xlsx 转换成功"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"xlsx 转换失败: {str(e)}"
|
||||
|
||||
|
||||
def convert_csv_encoding(input_path: str, output_path: str, source_encoding: str = 'auto') -> Tuple[bool, str]:
|
||||
"""
|
||||
将 csv 文件从 GBK/其他编码转换为 UTF-8
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
if source_encoding == 'auto':
|
||||
source_encoding = detect_encoding(input_path)
|
||||
|
||||
# 如果已经是 UTF-8,直接复制
|
||||
if source_encoding in ('utf-8', 'utf-8-sig'):
|
||||
if input_path != output_path:
|
||||
import shutil
|
||||
shutil.copy(input_path, output_path)
|
||||
return True, "文件已是 UTF-8 编码"
|
||||
|
||||
try:
|
||||
with open(input_path, 'r', encoding=source_encoding) as f_in:
|
||||
content = f_in.read()
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8', newline='') as f_out:
|
||||
f_out.write(content)
|
||||
|
||||
return True, f"编码转换成功: {source_encoding} -> utf-8"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"编码转换失败: {str(e)}"
|
||||
|
||||
|
||||
def convert_bill_file(input_path: str, output_path: Optional[str] = None) -> Tuple[bool, str, str, str]:
|
||||
"""
|
||||
转换账单文件为标准 CSV 格式(UTF-8 编码)
|
||||
|
||||
支持:
|
||||
- xlsx -> csv 转换
|
||||
- GBK/GB2312 -> UTF-8 编码转换
|
||||
|
||||
Args:
|
||||
input_path: 输入文件路径
|
||||
output_path: 输出文件路径(可选,默认在同目录生成)
|
||||
|
||||
Returns:
|
||||
(success, bill_type, output_path, message)
|
||||
"""
|
||||
input_path = Path(input_path)
|
||||
|
||||
if not input_path.exists():
|
||||
return False, '', '', f"文件不存在: {input_path}"
|
||||
|
||||
# 确定输出路径
|
||||
if output_path is None:
|
||||
# 生成临时文件
|
||||
suffix = '.csv'
|
||||
fd, output_path = tempfile.mkstemp(suffix=suffix)
|
||||
os.close(fd)
|
||||
|
||||
ext = input_path.suffix.lower()
|
||||
bill_type = ''
|
||||
|
||||
if ext == '.xlsx':
|
||||
# xlsx 转换
|
||||
success, message = convert_xlsx_to_csv(str(input_path), output_path)
|
||||
if not success:
|
||||
return False, '', '', message
|
||||
|
||||
# 读取内容检测账单类型
|
||||
with open(output_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read(2000) # 只读取前 2000 字符用于检测
|
||||
bill_type = detect_bill_type_from_content(content, input_path.name)
|
||||
|
||||
elif ext == '.csv':
|
||||
# CSV 编码转换
|
||||
success, message = convert_csv_encoding(str(input_path), output_path)
|
||||
if not success:
|
||||
return False, '', '', message
|
||||
|
||||
# 读取内容检测账单类型
|
||||
with open(output_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read(2000)
|
||||
bill_type = detect_bill_type_from_content(content, input_path.name)
|
||||
|
||||
else:
|
||||
return False, '', '', f"不支持的文件格式: {ext}"
|
||||
|
||||
return True, bill_type, output_path, "转换成功"
|
||||
@@ -2,3 +2,4 @@ pyyaml>=6.0
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
python-multipart>=0.0.6
|
||||
openpyxl>=3.1.0
|
||||
|
||||
@@ -22,8 +22,9 @@ 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 cleaners import AlipayCleaner, WechatCleaner, JDCleaner
|
||||
from category import infer_category, get_all_categories, get_all_income_categories
|
||||
from converter import convert_bill_file
|
||||
|
||||
# 应用版本
|
||||
APP_VERSION = "0.0.1"
|
||||
@@ -42,7 +43,7 @@ class CleanRequest(BaseModel):
|
||||
start: Optional[str] = None
|
||||
end: Optional[str] = None
|
||||
format: Optional[str] = "csv"
|
||||
bill_type: Optional[str] = "auto" # auto, alipay, wechat
|
||||
bill_type: Optional[str] = "auto" # auto, alipay, wechat, jd
|
||||
|
||||
|
||||
class CleanResponse(BaseModel):
|
||||
@@ -72,6 +73,14 @@ class HealthResponse(BaseModel):
|
||||
version: str
|
||||
|
||||
|
||||
class ConvertResponse(BaseModel):
|
||||
"""文件转换响应"""
|
||||
success: bool
|
||||
bill_type: str
|
||||
output_path: str
|
||||
message: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 辅助函数
|
||||
# =============================================================================
|
||||
@@ -81,11 +90,11 @@ def detect_bill_type(filepath: str) -> str | None:
|
||||
检测账单类型
|
||||
|
||||
Returns:
|
||||
'alipay' | 'wechat' | None
|
||||
'alipay' | 'wechat' | 'jd' | None
|
||||
"""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
for _ in range(20):
|
||||
for _ in range(50): # 支付宝账单可能有较多的头部信息行
|
||||
line = f.readline()
|
||||
if not line:
|
||||
break
|
||||
@@ -98,6 +107,14 @@ def detect_bill_type(filepath: str) -> str | None:
|
||||
if "交易类型" in line and "金额(元)" in line:
|
||||
return "wechat"
|
||||
|
||||
# 京东特征:表头包含 "商户名称" 和 "交易说明"
|
||||
if "商户名称" in line and "交易说明" in line:
|
||||
return "jd"
|
||||
|
||||
# 京东特征:头部信息包含 "京东账号名"
|
||||
if "京东账号名" in line:
|
||||
return "jd"
|
||||
|
||||
# 数据行特征
|
||||
if line.startswith("202"):
|
||||
if "¥" in line:
|
||||
@@ -146,14 +163,16 @@ def do_clean(
|
||||
try:
|
||||
if bill_type == "alipay":
|
||||
cleaner = AlipayCleaner(input_path, output_path, output_format)
|
||||
elif bill_type == "jd":
|
||||
cleaner = JDCleaner(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]}账单清洗完成"
|
||||
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
|
||||
return True, bill_type, f"✅ {type_names.get(bill_type, bill_type)}账单清洗完成"
|
||||
|
||||
except Exception as e:
|
||||
return False, bill_type, f"清洗失败: {str(e)}"
|
||||
@@ -315,7 +334,7 @@ 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:
|
||||
@@ -327,16 +346,53 @@ async def detect_bill_type_api(file: UploadFile = File(...)):
|
||||
if bill_type is None:
|
||||
raise HTTPException(status_code=400, detail="无法识别账单类型")
|
||||
|
||||
type_names = {"alipay": "支付宝", "wechat": "微信"}
|
||||
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
|
||||
return {
|
||||
"bill_type": bill_type,
|
||||
"display_name": type_names[bill_type]
|
||||
"display_name": type_names.get(bill_type, bill_type)
|
||||
}
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@app.post("/convert", response_model=ConvertResponse)
|
||||
async def convert_bill_file_api(file: UploadFile = File(...)):
|
||||
"""
|
||||
转换账单文件格式
|
||||
|
||||
支持:
|
||||
- xlsx -> csv 转换
|
||||
- GBK/GB2312 -> UTF-8 编码转换
|
||||
|
||||
返回转换后的文件路径和检测到的账单类型
|
||||
"""
|
||||
# 保存上传的文件到临时位置
|
||||
suffix = Path(file.filename).suffix or ".csv"
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
shutil.copyfileobj(file.file, tmp)
|
||||
input_path = tmp.name
|
||||
|
||||
try:
|
||||
# 调用转换函数
|
||||
success, bill_type, output_path, message = convert_bill_file(input_path)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return ConvertResponse(
|
||||
success=True,
|
||||
bill_type=bill_type,
|
||||
output_path=output_path,
|
||||
message=message
|
||||
)
|
||||
|
||||
finally:
|
||||
# 清理输入临时文件(转换后的输出文件由调用方负责清理)
|
||||
if os.path.exists(input_path):
|
||||
os.unlink(input_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 启动入口
|
||||
# =============================================================================
|
||||
|
||||
116
analyzer/test_jd_cleaner.py
Normal file
116
analyzer/test_jd_cleaner.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
测试京东账单清洗器
|
||||
"""
|
||||
import zipfile
|
||||
import tempfile
|
||||
import os
|
||||
import csv
|
||||
import sys
|
||||
|
||||
# 确保输出使用 UTF-8
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
def test_jd_cleaner():
|
||||
zip_path = r'D:\Projects\BillAI\mock_data\京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip'
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zf.extractall(tmpdir, pwd=b'683263')
|
||||
|
||||
# Find CSV file
|
||||
for f in os.listdir(tmpdir):
|
||||
if f.endswith('.csv'):
|
||||
input_file = os.path.join(tmpdir, f)
|
||||
output_file = os.path.join(tmpdir, 'output.csv')
|
||||
|
||||
print(f"Input file: {f}")
|
||||
print("-" * 60)
|
||||
|
||||
# Run cleaner
|
||||
from cleaners.jd import JDCleaner
|
||||
cleaner = JDCleaner(input_file, output_file)
|
||||
cleaner.clean()
|
||||
|
||||
# Read output and show review levels
|
||||
print("\n" + "=" * 60)
|
||||
print("OUTPUT REVIEW LEVELS")
|
||||
print("=" * 60)
|
||||
|
||||
with open(output_file, 'r', encoding='utf-8') as of:
|
||||
reader = csv.reader(of)
|
||||
header = next(reader)
|
||||
review_idx = header.index('复核等级') if '复核等级' in header else -1
|
||||
cat_idx = header.index('交易分类') if '交易分类' in header else -1
|
||||
merchant_idx = header.index('交易对方') if '交易对方' in header else -1
|
||||
desc_idx = header.index('商品说明') if '商品说明' in header else -1
|
||||
|
||||
stats = {'': 0, 'LOW': 0, 'HIGH': 0}
|
||||
rows_needing_review = []
|
||||
|
||||
for row in reader:
|
||||
review = row[review_idx] if review_idx >= 0 else ''
|
||||
stats[review] = stats.get(review, 0) + 1
|
||||
if review: # Collect rows that need review
|
||||
cat = row[cat_idx] if cat_idx >= 0 else ''
|
||||
merchant = row[merchant_idx][:20] if merchant_idx >= 0 else ''
|
||||
desc = row[desc_idx][:25] if desc_idx >= 0 else ''
|
||||
rows_needing_review.append((review, cat, merchant, desc))
|
||||
|
||||
# Print rows needing review
|
||||
print(f"{'Level':<5} | {'Category':<12} | {'Merchant':<20} | Description")
|
||||
print("-" * 70)
|
||||
for review, cat, merchant, desc in rows_needing_review:
|
||||
print(f"{review:<5} | {cat:<12} | {merchant:<20} | {desc}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("STATISTICS")
|
||||
print("=" * 60)
|
||||
print(f"No review (confident): {stats['']}")
|
||||
print(f"LOW (keyword match): {stats['LOW']}")
|
||||
print(f"HIGH (needs manual): {stats['HIGH']}")
|
||||
print(f"Total: {sum(stats.values())}")
|
||||
|
||||
|
||||
def test_infer_jd_category():
|
||||
"""测试分类推断逻辑"""
|
||||
from cleaners.jd import infer_jd_category
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("INFER_JD_CATEGORY TESTS")
|
||||
print("=" * 60)
|
||||
|
||||
tests = [
|
||||
# (商户, 商品, 原分类, 预期等级, 说明)
|
||||
('京东外卖', '火鸡面', '', 0, '商户映射'),
|
||||
('京东平台商户', 'xxx', '食品酒饮', 0, '原分类映射'),
|
||||
('京东平台商户', 'xxx', '数码电器', 0, '原分类映射'),
|
||||
('京东平台商户', 'xxx', '日用百货', 0, '原分类映射'),
|
||||
('京东平台商户', 'xxx', '图书文娱', 0, '原分类映射'),
|
||||
('京东平台商户', '猫粮', '其他', 1, '空映射+关键词成功'),
|
||||
('京东平台商户', '咖啡', '其他网购', 1, '空映射+关键词成功'),
|
||||
('京东平台商户', 'xxx', '其他', 2, '空映射+关键词失败'),
|
||||
('京东平台商户', 'xxx', '家居用品', 2, '未知分类'),
|
||||
('京东平台商户', 'xxx', '母婴', 2, '未知分类'),
|
||||
('京东平台商户', 'xxx', '', 2, '无原分类+关键词失败'),
|
||||
]
|
||||
|
||||
level_map = {0: 'NONE', 1: 'LOW', 2: 'HIGH'}
|
||||
|
||||
print(f"{'Merchant':<15} | {'Product':<8} | {'OrigCat':<10} | {'Result':<12} | {'Level':<5} | {'Expected':<5} | Note")
|
||||
print("-" * 90)
|
||||
|
||||
all_pass = True
|
||||
for merchant, product, orig_cat, expected_level, note in tests:
|
||||
cat, certain, level = infer_jd_category(merchant, product, orig_cat)
|
||||
status = "✓" if level == expected_level else "✗"
|
||||
if level != expected_level:
|
||||
all_pass = False
|
||||
print(f"{merchant:<15} | {product:<8} | {orig_cat or '(empty)':<10} | {cat:<12} | {level_map[level]:<5} | {level_map[expected_level]:<5} | {note} {status}")
|
||||
|
||||
print("\n" + ("All tests passed!" if all_pass else "Some tests FAILED!"))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_infer_jd_category()
|
||||
print("\n")
|
||||
test_jd_cleaner()
|
||||
150
deploy.sh
150
deploy.sh
@@ -1,116 +1,62 @@
|
||||
#!/bin/bash
|
||||
|
||||
# BillAI 自动部署脚本
|
||||
# 此脚本由 Gitea webhook 触发,自动执行 git pull 并重新构建部署
|
||||
# BillAI 部署脚本
|
||||
# 用于手动部署或 Gitea Actions 自动部署
|
||||
|
||||
set -e
|
||||
|
||||
REPO_ROOT="/app"
|
||||
LOG_FILE="/tmp/billai_deploy.log"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() {
|
||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE"
|
||||
echo -e "${GREEN}=== BillAI 部署开始 ===${NC}"
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo -e "\n${YELLOW}[1/4] 拉取最新代码${NC}"
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
echo "当前版本: $(git log -1 --format='%h %s')"
|
||||
|
||||
echo -e "\n${YELLOW}[2/4] 构建并部署服务${NC}"
|
||||
docker compose up -d --build --remove-orphans
|
||||
|
||||
echo -e "\n${YELLOW}[3/4] 清理旧镜像${NC}"
|
||||
docker image prune -f
|
||||
|
||||
echo -e "\n${YELLOW}[4/4] 健康检查${NC}"
|
||||
echo "等待服务启动..."
|
||||
sleep 15
|
||||
|
||||
# 检查服务状态(通过 Docker 健康检查状态)
|
||||
check_service() {
|
||||
local name=$1
|
||||
local container=$2
|
||||
local status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null)
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo -e " ${GREEN}✓${NC} $name 服务正常"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $name 服务异常 (状态: $status)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[错误]${NC} $1" | tee -a "$LOG_FILE"
|
||||
FAILED=0
|
||||
check_service "Web" "billai-web" || FAILED=1
|
||||
check_service "Server" "billai-server" || FAILED=1
|
||||
check_service "Analyzer" "billai-analyzer" || FAILED=1
|
||||
check_service "MongoDB" "billai-mongodb" || FAILED=1
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "\n${GREEN}=== 部署成功 ===${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "\n${RED}=== 部署失败:部分服务异常 ===${NC}"
|
||||
docker compose ps
|
||||
exit 1
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[成功]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log "=========================================="
|
||||
log "🚀 BillAI 自动部署开始"
|
||||
log "=========================================="
|
||||
|
||||
# 进入仓库目录
|
||||
cd "$REPO_ROOT" || error "无法进入仓库目录: $REPO_ROOT"
|
||||
log "📁 工作目录: $(pwd)"
|
||||
|
||||
# 拉取最新代码
|
||||
log "📥 正在拉取最新代码..."
|
||||
if ! git fetch origin; then
|
||||
error "git fetch 失败"
|
||||
fi
|
||||
|
||||
if ! git reset --hard origin/master; then
|
||||
error "git reset 失败"
|
||||
fi
|
||||
|
||||
success "代码已更新"
|
||||
|
||||
# 显示当前提交信息
|
||||
log "📝 当前提交:"
|
||||
git log --oneline -1
|
||||
|
||||
# 检查 docker-compose 是否存在
|
||||
if [ ! -f "docker-compose.yaml" ]; then
|
||||
error "docker-compose.yaml 不存在"
|
||||
fi
|
||||
|
||||
log "🐳 开始热更新部署(不停机)..."
|
||||
|
||||
# 定义需要重新部署的服务(排除 webhook 自身,否则会自杀)
|
||||
SERVICES="web server analyzer"
|
||||
|
||||
# 拉取基础镜像更新
|
||||
log "📦 检查基础镜像更新..."
|
||||
docker-compose pull mongodb mongo-express || true
|
||||
|
||||
# 热更新:重新构建并替换容器(不停止旧服务,直接替换)
|
||||
log "🔨 热更新服务(构建 + 替换)..."
|
||||
if ! docker-compose up -d --build --force-recreate --no-deps $SERVICES; then
|
||||
error "Docker 热更新失败"
|
||||
fi
|
||||
|
||||
# 清理旧的未使用镜像
|
||||
log "🧹 清理旧镜像..."
|
||||
docker image prune -f || true
|
||||
|
||||
# 等待服务启动
|
||||
log "⏳ 等待服务启动..."
|
||||
sleep 5
|
||||
|
||||
# 检查服务健康状态
|
||||
log "🏥 检查服务健康状态..."
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
|
||||
success "后端服务已启动"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
error "后端服务启动超时"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:3000 > /dev/null 2>&1; then
|
||||
success "前端服务已启动"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
error "前端服务启动超时"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log "=========================================="
|
||||
success "🎉 部署完成"
|
||||
log "=========================================="
|
||||
log "⏰ 完成时间: $(date +'%Y-%m-%d %H:%M:%S')"
|
||||
log "✅ 所有服务已启动并正常运行"
|
||||
log ""
|
||||
log "📍 访问地址:"
|
||||
log " 前端: http://localhost:3000"
|
||||
log " 后端: http://localhost:8080"
|
||||
log ""
|
||||
|
||||
42
docker-compose.runner.yaml
Normal file
42
docker-compose.runner.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Gitea Actions Runner - 自动部署
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. 在 Gitea 仓库获取 Runner Token
|
||||
# 访问:https://git.fadinglight.cn/clz/billai/settings/actions/runners
|
||||
# 点击 "Create new Runner" 复制 Token
|
||||
#
|
||||
# 2. 创建 .env 文件或设置环境变量
|
||||
# echo "GITEA_RUNNER_REGISTRATION_TOKEN=你的Token" > runner/.env
|
||||
#
|
||||
# 3. 启动 Runner
|
||||
# docker compose -f docker-compose.runner.yaml up -d
|
||||
#
|
||||
# 4. 在 Gitea 仓库添加变量
|
||||
# 访问:https://git.fadinglight.cn/clz/billai/settings/actions/variables
|
||||
# 添加 DEPLOY_PATH = /workspace/billai
|
||||
#
|
||||
# 模式说明:
|
||||
# 使用 Docker 模式,每个 Job 会在 docker:latest 容器中执行
|
||||
# 容器自带 docker CLI,通过挂载 docker.sock 控制宿主机的 Docker
|
||||
|
||||
services:
|
||||
runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: billai-runner
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./runner/.env
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: "https://git.fadinglight.cn"
|
||||
GITEA_RUNNER_NAME: "billai-runner"
|
||||
GITEA_RUNNER_LABELS: "self-hosted:docker://docker:latest,ubuntu-latest:docker://docker:latest"
|
||||
CONFIG_FILE: /config.yaml
|
||||
volumes:
|
||||
# Runner 配置文件
|
||||
- ./runner/config.yaml:/config.yaml
|
||||
# Runner 数据持久化
|
||||
- ./runner/data:/data
|
||||
# Docker socket - Runner 通过它创建 Job 容器
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 项目目录 - 挂载到 Runner 和 Job 容器都能访问的路径
|
||||
- .:/workspace/billai
|
||||
@@ -125,31 +125,3 @@ services:
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
|
||||
# Gitea Webhook 服务 - 自动部署
|
||||
webhook:
|
||||
build:
|
||||
context: ./webhook
|
||||
dockerfile: Dockerfile
|
||||
container_name: billai-webhook
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9000:9000"
|
||||
environment:
|
||||
WEBHOOK_PORT: "9000"
|
||||
# 重要:更改此 Secret 为强随机值
|
||||
# 生成方法: openssl rand -hex 32
|
||||
WEBHOOK_SECRET: "change-this-to-your-secret-key"
|
||||
REPO_PATH: "/app"
|
||||
volumes:
|
||||
# 挂载 Docker Socket 以支持容器操作
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 挂载仓库目录
|
||||
- /path/to/billai:/app
|
||||
working_dir: /app
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
BIN
mock_data/京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip
Normal file
BIN
mock_data/京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip
Normal file
Binary file not shown.
BIN
mock_data/微信支付账单(测试数据密码123456).zip
Normal file
BIN
mock_data/微信支付账单(测试数据密码123456).zip
Normal file
Binary file not shown.
BIN
mock_data/支付宝交易明细(测试数据密码123456).zip
Normal file
BIN
mock_data/支付宝交易明细(测试数据密码123456).zip
Normal file
Binary file not shown.
3
runner/.env.example
Normal file
3
runner/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Gitea Runner 配置
|
||||
# 从 Gitea 仓库获取 Token:Settings -> Actions -> Runners -> Create new Runner
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN=你的Token
|
||||
49
runner/config.yaml
Normal file
49
runner/config.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
# Gitea Actions Runner 配置
|
||||
# 文档: https://docs.gitea.com/usage/actions/act-runner
|
||||
# 模式: Docker 模式 - Job 在独立的 Docker 容器中执行
|
||||
|
||||
log:
|
||||
# 日志级别: debug, info, warn, error
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Runner 注册信息存储文件
|
||||
file: .runner
|
||||
# 同时运行的任务数量
|
||||
capacity: 1
|
||||
# 环境变量传递给 job
|
||||
envs: {}
|
||||
# 任务超时时间
|
||||
timeout: 1h
|
||||
# 关机超时时间
|
||||
shutdown_timeout: 3h
|
||||
# 是否获取远程任务时不进行 TLS 验证(不推荐)
|
||||
insecure: false
|
||||
# 任务容器拉取策略: always, if-not-present, never
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
# Runner 标签 - 使用 Docker 模式,docker:latest 镜像自带 docker CLI
|
||||
labels:
|
||||
- "ubuntu-latest:docker://docker:latest"
|
||||
- "self-hosted:docker://docker:latest"
|
||||
|
||||
container:
|
||||
# 容器网络模式
|
||||
network: "host"
|
||||
# 是否启用特权模式
|
||||
privileged: false
|
||||
# 容器启动选项 - 挂载 docker.sock 和项目目录
|
||||
options: "-v /var/run/docker.sock:/var/run/docker.sock"
|
||||
# 工作目录父路径
|
||||
workdir_parent:
|
||||
# 有效的卷挂载 - 允许挂载的目录
|
||||
valid_volumes:
|
||||
- /**
|
||||
# Docker 主机
|
||||
docker_host: ""
|
||||
# 强制拉取镜像
|
||||
force_pull: false
|
||||
|
||||
host:
|
||||
# 主机工作目录
|
||||
workdir_parent:
|
||||
25
server/.dockerignore
Normal file
25
server/.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
# Ignore these files for docker build context
|
||||
# Binaries
|
||||
server
|
||||
billai-server
|
||||
*.exe
|
||||
*.dll
|
||||
|
||||
# Dependencies (if any are local and not in go.mod/go.sum, unlikely for Go)
|
||||
vendor/
|
||||
|
||||
# Logs and outputs
|
||||
*.log
|
||||
outputs/
|
||||
uploads/
|
||||
|
||||
# IDE config
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -13,10 +13,16 @@ type CleanOptions struct {
|
||||
|
||||
// CleanResult 清洗结果
|
||||
type CleanResult struct {
|
||||
BillType string // 检测到的账单类型: alipay/wechat
|
||||
BillType string // 检测到的账单类型: alipay/wechat/jd
|
||||
Output string // 脚本输出信息
|
||||
}
|
||||
|
||||
// ConvertResult 格式转换结果
|
||||
type ConvertResult struct {
|
||||
OutputPath string // 转换后的文件路径
|
||||
BillType string // 检测到的账单类型: alipay/wechat/jd
|
||||
}
|
||||
|
||||
// Cleaner 账单清洗器接口
|
||||
// 负责将原始账单数据清洗为标准格式
|
||||
type Cleaner interface {
|
||||
@@ -25,4 +31,9 @@ type Cleaner interface {
|
||||
// outputPath: 输出文件路径
|
||||
// opts: 清洗选项
|
||||
Clean(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error)
|
||||
|
||||
// Convert 转换账单文件格式(xlsx -> csv,处理 GBK 编码等)
|
||||
// inputPath: 输入文件路径
|
||||
// 返回: 转换后的文件路径, 检测到的账单类型, 错误
|
||||
Convert(inputPath string) (outputPath string, billType string, err error)
|
||||
}
|
||||
|
||||
@@ -185,6 +185,88 @@ func (c *Cleaner) downloadFile(remotePath, localPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertResponse 转换响应
|
||||
type ConvertResponse struct {
|
||||
Success bool `json:"success"`
|
||||
BillType string `json:"bill_type"`
|
||||
Message string `json:"message"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
}
|
||||
|
||||
// Convert 转换账单文件格式(xlsx -> csv,处理 GBK 编码等)
|
||||
func (c *Cleaner) Convert(inputPath string) (outputPath string, billType string, err error) {
|
||||
// 打开输入文件
|
||||
file, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("打开文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 创建 multipart form
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// 添加文件
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(inputPath))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("创建表单文件失败: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return "", "", fmt.Errorf("复制文件内容失败: %w", err)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
// 发送转换请求
|
||||
fmt.Printf("🌐 调用转换服务: %s/convert\n", c.baseURL)
|
||||
req, err := http.NewRequest("POST", c.baseURL+"/convert", &body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("HTTP 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 处理错误响应
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(respBody, &errResp); err == nil {
|
||||
return "", "", fmt.Errorf("转换失败: %s", errResp.Detail)
|
||||
}
|
||||
return "", "", fmt.Errorf("转换失败: HTTP %d - %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// 解析成功响应
|
||||
var convertResp ConvertResponse
|
||||
if err := json.Unmarshal(respBody, &convertResp); err != nil {
|
||||
return "", "", fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 下载转换后的文件到本地(与输入文件同目录,但扩展名改为 .csv)
|
||||
localOutputPath := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))] + ".csv"
|
||||
fmt.Printf(" 下载转换后文件: %s -> %s\n", convertResp.OutputPath, localOutputPath)
|
||||
if err := c.downloadFile(convertResp.OutputPath, localOutputPath); err != nil {
|
||||
return "", "", fmt.Errorf("下载转换结果失败: %w", err)
|
||||
}
|
||||
|
||||
// 验证文件是否存在
|
||||
if _, err := os.Stat(localOutputPath); err != nil {
|
||||
return "", "", fmt.Errorf("下载后文件不存在: %s", localOutputPath)
|
||||
}
|
||||
fmt.Printf(" 文件下载成功,已保存到: %s\n", localOutputPath)
|
||||
|
||||
return localOutputPath, convertResp.BillType, nil
|
||||
}
|
||||
|
||||
// HealthCheck 检查 Python 服务健康状态
|
||||
func (c *Cleaner) HealthCheck() error {
|
||||
resp, err := c.httpClient.Get(c.baseURL + "/health")
|
||||
|
||||
@@ -87,8 +87,17 @@ func detectBillTypeFromOutput(output string) string {
|
||||
if strings.Contains(output, "微信") {
|
||||
return "wechat"
|
||||
}
|
||||
if strings.Contains(output, "京东") {
|
||||
return "jd"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Convert 转换账单文件格式(xlsx -> csv,处理 GBK 编码等)
|
||||
// 子进程模式不支持此功能,请使用 HTTP 模式
|
||||
func (c *Cleaner) Convert(inputPath string) (outputPath string, billType string, err error) {
|
||||
return "", "", fmt.Errorf("子进程模式不支持文件格式转换,请使用 HTTP 模式 (analyzer_mode: http)")
|
||||
}
|
||||
|
||||
// 确保 Cleaner 实现了 adapter.Cleaner 接口
|
||||
var _ adapter.Cleaner = (*Cleaner)(nil)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# BillAI 服务器配置文件
|
||||
|
||||
# 应用版本
|
||||
version: "1.0.5"
|
||||
version: "1.0.7"
|
||||
|
||||
# 服务配置
|
||||
server:
|
||||
|
||||
@@ -145,7 +145,7 @@ func Load() {
|
||||
flag.Parse()
|
||||
|
||||
// 设置默认值
|
||||
Global.Version = "0.0.1"
|
||||
Global.Version = "1.0.7"
|
||||
Global.Port = getEnvOrDefault("PORT", "8080")
|
||||
Global.ProjectRoot = getDefaultProjectRoot()
|
||||
Global.PythonPath = getDefaultPythonPath()
|
||||
|
||||
@@ -69,4 +69,3 @@ func Disconnect() error {
|
||||
fmt.Println("🍃 MongoDB 连接已断开")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9
|
||||
go.mongodb.org/mongo-driver v1.13.1
|
||||
golang.org/x/text v0.9.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -17,7 +20,6 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.13.6 // indirect
|
||||
@@ -39,6 +41,5 @@ require (
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
)
|
||||
|
||||
@@ -75,6 +75,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M=
|
||||
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -131,6 +132,7 @@ func ValidateToken(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "未提供 Token",
|
||||
"code": "TOKEN_MISSING",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -147,12 +149,20 @@ func ValidateToken(c *gin.Context) {
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
code := "TOKEN_INVALID"
|
||||
message := "Token 无效"
|
||||
if err != nil && errors.Is(err, jwt.ErrTokenExpired) {
|
||||
code = "TOKEN_EXPIRED"
|
||||
message = "Token 已过期"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "Token 无效或已过期",
|
||||
"error": message,
|
||||
"code": code,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -162,6 +172,7 @@ func ValidateToken(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "Token 解析失败",
|
||||
"code": "TOKEN_INVALID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type ListBillsRequest struct {
|
||||
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
|
||||
Type string `form:"type"` // 账单类型 alipay/wechat/jd
|
||||
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
|
||||
}
|
||||
|
||||
@@ -58,9 +58,9 @@ func ListBills(c *gin.Context) {
|
||||
if req.PageSize < 1 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100 // 限制最大每页数量
|
||||
}
|
||||
// if req.PageSize > 100 {
|
||||
// req.PageSize = 100 // 限制最大每页数量
|
||||
// }
|
||||
|
||||
// 构建筛选条件
|
||||
filter := make(map[string]interface{})
|
||||
|
||||
42
server/handler/delete_bill.go
Normal file
42
server/handler/delete_bill.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"billai-server/repository"
|
||||
)
|
||||
|
||||
type DeleteBillResponse struct {
|
||||
Result bool `json:"result"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteBill POST /api/bills/:id/delete 删除清洗后的账单记录
|
||||
func DeleteBill(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, DeleteBillResponse{Result: false, Message: "缺少账单 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
repo := repository.GetRepository()
|
||||
if repo == nil {
|
||||
c.JSON(http.StatusInternalServerError, DeleteBillResponse{Result: false, Message: "数据库未连接"})
|
||||
return
|
||||
}
|
||||
|
||||
err := repo.DeleteCleanedBillByID(id)
|
||||
if err != nil {
|
||||
if err == repository.ErrNotFound {
|
||||
c.JSON(http.StatusNotFound, DeleteBillResponse{Result: false, Message: "账单不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, DeleteBillResponse{Result: false, Message: "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, DeleteBillResponse{Result: true, Message: "删除成功"})
|
||||
}
|
||||
@@ -1,61 +1,59 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"billai-server/config"
|
||||
"billai-server/model"
|
||||
"billai-server/service"
|
||||
"billai-server/repository"
|
||||
)
|
||||
|
||||
// Review 获取需要复核的记录
|
||||
func Review(c *gin.Context) {
|
||||
// 获取文件名参数
|
||||
fileName := c.Query("file")
|
||||
if fileName == "" {
|
||||
c.JSON(http.StatusBadRequest, model.ReviewResponse{
|
||||
// 获取数据
|
||||
repo := repository.GetRepository()
|
||||
if repo == nil {
|
||||
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
|
||||
Result: false,
|
||||
Message: "请提供文件名参数 (file)",
|
||||
Message: "数据库未连接",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建文件路径
|
||||
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
|
||||
filePath := filepath.Join(outputDirAbs, fileName)
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, model.ReviewResponse{
|
||||
// 从MongoDB查询所有需要复核的账单
|
||||
bills, err := repo.GetBillsNeedReview()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
|
||||
Result: false,
|
||||
Message: "文件不存在: " + fileName,
|
||||
Message: "查询失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 判断文件格式
|
||||
format := "csv"
|
||||
if strings.HasSuffix(fileName, ".json") {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// 提取需要复核的记录
|
||||
records := service.ExtractNeedsReview(filePath, format)
|
||||
|
||||
// 统计高低优先级数量
|
||||
// 统计高低优先级数量并转换为 ReviewRecord
|
||||
highCount := 0
|
||||
lowCount := 0
|
||||
for _, r := range records {
|
||||
if r.ReviewLevel == "HIGH" {
|
||||
records := make([]model.ReviewRecord, 0, len(bills))
|
||||
|
||||
for _, bill := range bills {
|
||||
if bill.ReviewLevel == "HIGH" {
|
||||
highCount++
|
||||
} else if r.ReviewLevel == "LOW" {
|
||||
} else if bill.ReviewLevel == "LOW" {
|
||||
lowCount++
|
||||
}
|
||||
|
||||
records = append(records, model.ReviewRecord{
|
||||
Time: bill.Time.Time().Format("2006-01-02 15:04:05"),
|
||||
Category: bill.Category,
|
||||
Merchant: bill.Merchant,
|
||||
Description: bill.Description,
|
||||
IncomeExpense: bill.IncomeExpense,
|
||||
Amount: fmt.Sprintf("%.2f", bill.Amount),
|
||||
Remark: bill.Remark,
|
||||
ReviewLevel: bill.ReviewLevel,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.ReviewResponse{
|
||||
@@ -69,4 +67,3 @@ func Review(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
156
server/handler/update_bill.go
Normal file
156
server/handler/update_bill.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"billai-server/model"
|
||||
"billai-server/repository"
|
||||
)
|
||||
|
||||
// UpdateBillRequest 账单更新请求(字段均为可选)
|
||||
type UpdateBillRequest struct {
|
||||
Time *string `json:"time,omitempty"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Merchant *string `json:"merchant,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
IncomeExpense *string `json:"income_expense,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
PayMethod *string `json:"pay_method,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Remark *string `json:"remark,omitempty"`
|
||||
ReviewLevel *string `json:"review_level,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateBillResponse struct {
|
||||
Result bool `json:"result"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data *model.CleanedBill `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func parseBillTime(s string) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
formats := []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.ParseInLocation(f, s, time.Local); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unsupported time format")
|
||||
}
|
||||
|
||||
// UpdateBill POST /api/bills/:id 更新清洗后的账单记录
|
||||
func UpdateBill(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "缺少账单 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateBillRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "参数解析失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if req.Time != nil {
|
||||
t, err := parseBillTime(*req.Time)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "时间格式错误"})
|
||||
return
|
||||
}
|
||||
updates["time"] = t
|
||||
}
|
||||
|
||||
if req.Category != nil {
|
||||
v := strings.TrimSpace(*req.Category)
|
||||
if v == "" {
|
||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "分类不能为空"})
|
||||
return
|
||||
}
|
||||
updates["category"] = v
|
||||
}
|
||||
|
||||
if req.Merchant != nil {
|
||||
v := strings.TrimSpace(*req.Merchant)
|
||||
if v == "" {
|
||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "商家不能为空"})
|
||||
return
|
||||
}
|
||||
updates["merchant"] = v
|
||||
}
|
||||
|
||||
if req.Description != nil {
|
||||
updates["description"] = strings.TrimSpace(*req.Description)
|
||||
}
|
||||
|
||||
if req.IncomeExpense != nil {
|
||||
v := strings.TrimSpace(*req.IncomeExpense)
|
||||
if v != "" && v != "收入" && v != "支出" {
|
||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "income_expense 只能是 收入 或 支出"})
|
||||
return
|
||||
}
|
||||
updates["income_expense"] = v
|
||||
}
|
||||
|
||||
if req.Amount != nil {
|
||||
updates["amount"] = *req.Amount
|
||||
}
|
||||
|
||||
if req.PayMethod != nil {
|
||||
updates["pay_method"] = strings.TrimSpace(*req.PayMethod)
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
updates["status"] = strings.TrimSpace(*req.Status)
|
||||
}
|
||||
|
||||
if req.Remark != nil {
|
||||
updates["remark"] = strings.TrimSpace(*req.Remark)
|
||||
}
|
||||
|
||||
if req.ReviewLevel != nil {
|
||||
// 允许设置为空字符串(清除复核等级)或 HIGH/LOW
|
||||
v := strings.TrimSpace(*req.ReviewLevel)
|
||||
if v != "" && v != "HIGH" && v != "LOW" {
|
||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "review_level 只能是空、HIGH 或 LOW"})
|
||||
return
|
||||
}
|
||||
updates["review_level"] = v
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "没有可更新的字段"})
|
||||
return
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
repo := repository.GetRepository()
|
||||
if repo == nil {
|
||||
c.JSON(http.StatusInternalServerError, UpdateBillResponse{Result: false, Message: "数据库未连接"})
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := repo.UpdateCleanedBillByID(id, updates)
|
||||
if err != nil {
|
||||
if err == repository.ErrNotFound {
|
||||
c.JSON(http.StatusNotFound, UpdateBillResponse{Result: false, Message: "账单不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, UpdateBillResponse{Result: false, Message: "更新失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, UpdateBillResponse{Result: true, Message: "更新成功", Data: updated})
|
||||
}
|
||||
@@ -8,16 +8,20 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"billai-server/config"
|
||||
"billai-server/model"
|
||||
"billai-server/repository"
|
||||
"billai-server/service"
|
||||
)
|
||||
|
||||
// Upload 处理账单上传和清理请求
|
||||
// 支持直接上传 CSV 文件,或上传 ZIP 压缩包(支持密码保护)
|
||||
// ZIP 包内可以是 CSV 或 XLSX 格式的账单文件
|
||||
func Upload(c *gin.Context) {
|
||||
// 1. 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
@@ -37,32 +41,12 @@ func Upload(c *gin.Context) {
|
||||
req.Format = "csv"
|
||||
}
|
||||
|
||||
// 验证 type 参数
|
||||
if req.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "请指定账单类型 (type: alipay 或 wechat)",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.Type != "alipay" && req.Type != "wechat" {
|
||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "账单类型无效,仅支持 alipay 或 wechat",
|
||||
})
|
||||
return
|
||||
}
|
||||
billType := req.Type
|
||||
|
||||
// 3. 保存上传的文件(添加唯一ID避免覆盖)
|
||||
// 3. 保存上传的文件
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
uniqueID := generateShortID()
|
||||
|
||||
// 获取文件扩展名和基础名
|
||||
ext := filepath.Ext(header.Filename)
|
||||
baseName := header.Filename[:len(header.Filename)-len(ext)]
|
||||
|
||||
// 文件名格式: 时间戳_唯一ID_原始文件名
|
||||
inputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, uniqueID, baseName, ext)
|
||||
uploadDirAbs := config.ResolvePath(config.Global.UploadDir)
|
||||
inputPath := filepath.Join(uploadDirAbs, inputFileName)
|
||||
@@ -76,12 +60,119 @@ func Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
io.Copy(dst, file)
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "保存文件失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
dst.Close() // 关闭文件以便后续处理
|
||||
|
||||
// 4. 对原始数据进行去重检查
|
||||
// 4. 处理文件:如果是 ZIP 则解压,否则直接处理
|
||||
var billFilePath string
|
||||
var billType string
|
||||
var extractedFiles []string
|
||||
var needConvert bool // 是否需要格式转换(xlsx -> csv)
|
||||
|
||||
if service.IsSupportedArchive(header.Filename) {
|
||||
// 解压 ZIP 文件
|
||||
fmt.Printf("📦 检测到 ZIP 文件,开始解压...\n")
|
||||
extractResult, err := service.ExtractZip(inputPath, uploadDirAbs, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "解压失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
billFilePath = extractResult.BillFile
|
||||
extractedFiles = extractResult.ExtractedFiles
|
||||
|
||||
// 使用从文件名检测到的账单类型(如果用户未指定)
|
||||
if req.Type == "" && extractResult.BillType != "" {
|
||||
billType = extractResult.BillType
|
||||
}
|
||||
|
||||
fmt.Printf(" 解压完成,账单文件: %s\n", filepath.Base(billFilePath))
|
||||
|
||||
// ZIP 中提取的文件需要格式转换(xlsx 需要转 csv,csv 可能需要编码转换)
|
||||
needConvert = true
|
||||
} else {
|
||||
// 直接使用上传的文件
|
||||
billFilePath = inputPath
|
||||
|
||||
// 检查是否为 xlsx 格式
|
||||
if strings.HasSuffix(strings.ToLower(header.Filename), ".xlsx") {
|
||||
needConvert = true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 如果需要格式/编码转换,调用 analyzer 服务
|
||||
if needConvert {
|
||||
fmt.Printf("📊 调用分析服务进行格式/编码转换...\n")
|
||||
convertedPath, detectedType, err := service.ConvertBillFile(billFilePath)
|
||||
if err != nil {
|
||||
// 清理临时文件
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "文件转换失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// 如果转换后的路径与原路径不同,删除原始文件
|
||||
if convertedPath != billFilePath {
|
||||
os.Remove(billFilePath)
|
||||
}
|
||||
billFilePath = convertedPath
|
||||
|
||||
// 使用检测到的账单类型
|
||||
if req.Type == "" && detectedType != "" {
|
||||
billType = detectedType
|
||||
}
|
||||
fmt.Printf(" 转换完成: %s\n", filepath.Base(convertedPath))
|
||||
}
|
||||
|
||||
// 6. 确定账单类型
|
||||
if req.Type != "" {
|
||||
billType = req.Type
|
||||
}
|
||||
if billType == "" {
|
||||
// 尝试从文件名检测
|
||||
fileName := strings.ToLower(filepath.Base(billFilePath))
|
||||
if strings.Contains(fileName, "支付宝") || strings.Contains(fileName, "alipay") {
|
||||
billType = "alipay"
|
||||
} else if strings.Contains(fileName, "微信") || strings.Contains(fileName, "wechat") {
|
||||
billType = "wechat"
|
||||
} else if strings.Contains(fileName, "京东") || strings.Contains(fileName, "jd") {
|
||||
billType = "jd"
|
||||
}
|
||||
}
|
||||
if billType == "" {
|
||||
// 清理临时文件
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "无法识别账单类型,请指定 type 参数 (alipay/wechat/jd)",
|
||||
})
|
||||
return
|
||||
}
|
||||
if billType != "alipay" && billType != "wechat" && billType != "jd" {
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "账单类型无效,仅支持 alipay/wechat/jd",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 7. 对原始数据进行去重检查
|
||||
fmt.Printf("📋 开始去重检查...\n")
|
||||
dedupResult, dedupErr := service.DeduplicateRawFile(inputPath, timestamp)
|
||||
dedupResult, dedupErr := service.DeduplicateRawFile(billFilePath, timestamp)
|
||||
if dedupErr != nil {
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "去重检查失败: " + dedupErr.Error(),
|
||||
@@ -97,6 +188,7 @@ func Upload(c *gin.Context) {
|
||||
|
||||
// 如果全部重复,返回提示
|
||||
if dedupResult.NewCount == 0 {
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
c.JSON(http.StatusOK, model.UploadResponse{
|
||||
Result: true,
|
||||
Message: fmt.Sprintf("文件中的 %d 条记录全部已存在,无需重复导入", dedupResult.OriginalCount),
|
||||
@@ -113,7 +205,7 @@ func Upload(c *gin.Context) {
|
||||
// 使用去重后的文件路径进行后续处理
|
||||
processFilePath := dedupResult.DedupFilePath
|
||||
|
||||
// 5. 构建输出文件路径:时间_type_编号
|
||||
// 8. 构建输出文件路径
|
||||
outputExt := ".csv"
|
||||
if req.Format == "json" {
|
||||
outputExt = ".json"
|
||||
@@ -123,7 +215,7 @@ func Upload(c *gin.Context) {
|
||||
outputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, billType, fileSeq, outputExt)
|
||||
outputPath := filepath.Join(outputDirAbs, outputFileName)
|
||||
|
||||
// 6. 执行 Python 清洗脚本
|
||||
// 9. 执行 Python 清洗脚本
|
||||
cleanOpts := &service.CleanOptions{
|
||||
Year: req.Year,
|
||||
Month: req.Month,
|
||||
@@ -133,6 +225,7 @@ func Upload(c *gin.Context) {
|
||||
}
|
||||
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
||||
if cleanErr != nil {
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: cleanErr.Error(),
|
||||
@@ -140,7 +233,7 @@ func Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 7. 将去重后的原始数据存入 MongoDB(原始数据集合)
|
||||
// 10. 将去重后的原始数据存入 MongoDB
|
||||
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
|
||||
if rawErr != nil {
|
||||
fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr)
|
||||
@@ -148,7 +241,7 @@ func Upload(c *gin.Context) {
|
||||
fmt.Printf("✅ 已存储 %d 条原始账单记录到 MongoDB\n", rawCount)
|
||||
}
|
||||
|
||||
// 9. 将清洗后的数据存入 MongoDB(清洗后数据集合)
|
||||
// 11. 将清洗后的数据存入 MongoDB
|
||||
cleanedCount, _, cleanedErr := service.SaveCleanedBillsFromFile(outputPath, req.Format, billType, header.Filename, timestamp)
|
||||
if cleanedErr != nil {
|
||||
fmt.Printf("⚠️ 存储清洗后数据到 MongoDB 失败: %v\n", cleanedErr)
|
||||
@@ -156,33 +249,52 @@ func Upload(c *gin.Context) {
|
||||
fmt.Printf("✅ 已存储 %d 条清洗后账单记录到 MongoDB\n", cleanedCount)
|
||||
}
|
||||
|
||||
// 10. 清理临时的去重文件(如果生成了的话)
|
||||
// 12. 清理临时文件
|
||||
if dedupResult.DedupFilePath != inputPath && dedupResult.DedupFilePath != "" {
|
||||
os.Remove(dedupResult.DedupFilePath)
|
||||
}
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
|
||||
// 11. 返回成功响应
|
||||
// 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录
|
||||
var jdRelatedDeleted int64
|
||||
if billType == "jd" {
|
||||
repo := repository.GetRepository()
|
||||
if repo != nil {
|
||||
deleted, err := repo.SoftDeleteJDRelatedBills()
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
jdRelatedDeleted = deleted
|
||||
fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 14. 返回成功响应
|
||||
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
|
||||
if dedupResult.DuplicateCount > 0 {
|
||||
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
||||
}
|
||||
if jdRelatedDeleted > 0 {
|
||||
message = fmt.Sprintf("%s,标记删除 %d 条重复的京东订单", message, jdRelatedDeleted)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.UploadResponse{
|
||||
Result: true,
|
||||
Message: message,
|
||||
Data: &model.UploadData{
|
||||
BillType: billType,
|
||||
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
||||
FileName: outputFileName,
|
||||
RawCount: rawCount,
|
||||
CleanedCount: cleanedCount,
|
||||
DuplicateCount: dedupResult.DuplicateCount,
|
||||
BillType: billType,
|
||||
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
||||
FileName: outputFileName,
|
||||
RawCount: rawCount,
|
||||
CleanedCount: cleanedCount,
|
||||
DuplicateCount: dedupResult.DuplicateCount,
|
||||
JDRelatedDeleted: jdRelatedDeleted,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// generateFileSequence 生成文件序号
|
||||
// 根据当前目录下同一时间戳和类型的文件数量生成序号
|
||||
func generateFileSequence(dir, timestamp, billType, ext string) string {
|
||||
pattern := fmt.Sprintf("%s_%s_*%s", timestamp, billType, ext)
|
||||
matches, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
@@ -194,9 +306,8 @@ func generateFileSequence(dir, timestamp, billType, ext string) string {
|
||||
|
||||
// generateShortID 生成 6 位随机唯一标识符
|
||||
func generateShortID() string {
|
||||
bytes := make([]byte, 3) // 3 字节 = 6 个十六进制字符
|
||||
bytes := make([]byte, 3)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
// 如果随机数生成失败,使用时间纳秒作为备选
|
||||
return fmt.Sprintf("%06x", time.Now().UnixNano()%0xFFFFFF)
|
||||
}
|
||||
return hex.EncodeToString(bytes)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
adapterHttp "billai-server/adapter/http"
|
||||
"billai-server/adapter/python"
|
||||
"billai-server/config"
|
||||
"billai-server/database"
|
||||
"billai-server/repository"
|
||||
repoMongo "billai-server/repository/mongo"
|
||||
"billai-server/router"
|
||||
@@ -44,21 +43,13 @@ func main() {
|
||||
initAdapters()
|
||||
|
||||
// 初始化数据层
|
||||
if err := initRepository(); err != nil {
|
||||
repo, err := initRepository()
|
||||
if 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(" 账单数据将不会存储到数据库")
|
||||
os.Exit(1)
|
||||
} else {
|
||||
// 优雅关闭时断开连接
|
||||
defer database.Disconnect()
|
||||
}
|
||||
defer repo.Disconnect()
|
||||
|
||||
// 创建路由
|
||||
r := gin.Default()
|
||||
@@ -75,7 +66,7 @@ func main() {
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
fmt.Println("\n🛑 正在关闭服务...")
|
||||
database.Disconnect()
|
||||
repo.Disconnect()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
@@ -153,14 +144,14 @@ func initAdapters() {
|
||||
// initRepository 初始化数据存储层
|
||||
// 在这里配置数据持久化方式
|
||||
// 后续可以通过修改这里来切换不同的存储实现(如 PostgreSQL、MySQL 等)
|
||||
func initRepository() error {
|
||||
func initRepository() (repository.BillRepository, error) {
|
||||
// 初始化 MongoDB 存储
|
||||
mongoRepo := repoMongo.NewRepository()
|
||||
if err := mongoRepo.Connect(); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
repository.SetRepository(mongoRepo)
|
||||
|
||||
fmt.Println("💾 数据层初始化完成")
|
||||
return nil
|
||||
return mongoRepo, nil
|
||||
}
|
||||
|
||||
75
server/middleware/auth.go
Normal file
75
server/middleware/auth.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"billai-server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims JWT claims (duplicated here to avoid cross-package import from handler).
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenString := c.GetHeader("Authorization")
|
||||
if tokenString == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "未提供 Token",
|
||||
"code": "TOKEN_MISSING",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(tokenString, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(tokenString, "Bearer ")
|
||||
}
|
||||
|
||||
secret := config.Global.JWTSecret
|
||||
if secret == "" {
|
||||
secret = "billai-default-secret"
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
code := "TOKEN_INVALID"
|
||||
message := "Token 无效"
|
||||
if err != nil && errors.Is(err, jwt.ErrTokenExpired) {
|
||||
code = "TOKEN_EXPIRED"
|
||||
message = "Token 已过期"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": message,
|
||||
"code": code,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok {
|
||||
c.Set("user", gin.H{
|
||||
"username": claims.Username,
|
||||
"name": claims.Name,
|
||||
"role": claims.Role,
|
||||
})
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func (t LocalTime) Time() time.Time {
|
||||
// RawBill 原始账单记录(存储上传的原始数据)
|
||||
type RawBill struct {
|
||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||||
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
|
||||
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat/jd
|
||||
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
||||
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
||||
RowIndex int `bson:"row_index" json:"row_index"` // 原始行号
|
||||
@@ -81,7 +81,7 @@ type RawBill struct {
|
||||
// CleanedBill 清洗后账单记录(标准化后的数据)
|
||||
type CleanedBill struct {
|
||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||||
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
|
||||
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat/jd
|
||||
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
|
||||
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
|
||||
Time LocalTime `bson:"time" json:"time"` // 交易时间(本地时间格式)
|
||||
@@ -94,6 +94,7 @@ type CleanedBill struct {
|
||||
Status string `bson:"status" json:"status"` // 交易状态
|
||||
Remark string `bson:"remark" json:"remark"` // 备注
|
||||
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
|
||||
IsDeleted bool `bson:"is_deleted" json:"is_deleted"` // 是否已删除(软删除)
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` // 更新时间
|
||||
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
||||
|
||||
@@ -2,10 +2,11 @@ package model
|
||||
|
||||
// UploadRequest 上传请求参数
|
||||
type UploadRequest struct {
|
||||
Type string `form:"type"` // 账单类型: alipay/wechat(必填)
|
||||
Year string `form:"year"` // 年份筛选
|
||||
Month string `form:"month"` // 月份筛选
|
||||
Start string `form:"start"` // 起始日期
|
||||
End string `form:"end"` // 结束日期
|
||||
Format string `form:"format"` // 输出格式: csv/json
|
||||
Type string `form:"type"` // 账单类型: alipay/wechat/jd(可选,会自动检测)
|
||||
Password string `form:"password"` // ZIP 文件密码(可选)
|
||||
Year string `form:"year"` // 年份筛选
|
||||
Month string `form:"month"` // 月份筛选
|
||||
Start string `form:"start"` // 起始日期
|
||||
End string `form:"end"` // 结束日期
|
||||
Format string `form:"format"` // 输出格式: csv/json
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ package model
|
||||
|
||||
// UploadData 上传响应数据
|
||||
type UploadData struct {
|
||||
BillType string `json:"bill_type,omitempty"` // alipay/wechat
|
||||
FileURL string `json:"file_url,omitempty"` // 下载链接
|
||||
FileName string `json:"file_name,omitempty"` // 文件名
|
||||
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
|
||||
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
|
||||
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
|
||||
BillType string `json:"bill_type,omitempty"` // alipay/wechat/jd
|
||||
FileURL string `json:"file_url,omitempty"` // 下载链接
|
||||
FileName string `json:"file_name,omitempty"` // 文件名
|
||||
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
|
||||
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
|
||||
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
|
||||
JDRelatedDeleted int64 `json:"jd_related_deleted,omitempty"` // 软删除的京东关联记录数(其他来源中描述包含京东订单号的记录)
|
||||
}
|
||||
|
||||
// UploadResponse 上传响应
|
||||
@@ -43,4 +44,3 @@ type ReviewResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data *ReviewData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
6
server/repository/errors.go
Normal file
6
server/repository/errors.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package repository
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrNotFound 表示目标记录不存在
|
||||
var ErrNotFound = errors.New("not found")
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
|
||||
@@ -191,6 +192,9 @@ func (r *Repository) GetCleanedBills(filter map[string]interface{}) ([]model.Cle
|
||||
bsonFilter[k] = v
|
||||
}
|
||||
|
||||
// 排除已删除的记录
|
||||
bsonFilter["is_deleted"] = bson.M{"$ne": true}
|
||||
|
||||
// 按时间倒序排列
|
||||
opts := options.Find().SetSort(bson.D{{Key: "time", Value: -1}})
|
||||
|
||||
@@ -219,6 +223,9 @@ func (r *Repository) GetCleanedBillsPaged(filter map[string]interface{}, page, p
|
||||
bsonFilter[k] = v
|
||||
}
|
||||
|
||||
// 排除已删除的记录
|
||||
bsonFilter["is_deleted"] = bson.M{"$ne": true}
|
||||
|
||||
// 计算总数
|
||||
total, err := r.cleanedCollection.CountDocuments(ctx, bsonFilter)
|
||||
if err != nil {
|
||||
@@ -259,6 +266,9 @@ func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpe
|
||||
bsonFilter[k] = v
|
||||
}
|
||||
|
||||
// 排除已删除的记录
|
||||
bsonFilter["is_deleted"] = bson.M{"$ne": true}
|
||||
|
||||
// 使用聚合管道按 income_expense 分组统计金额
|
||||
pipeline := mongo.Pipeline{
|
||||
{{Key: "$match", Value: bsonFilter}},
|
||||
@@ -299,6 +309,7 @@ func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpe
|
||||
func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) {
|
||||
filter := map[string]interface{}{
|
||||
"review_level": bson.M{"$in": []string{"HIGH", "LOW"}},
|
||||
"is_deleted": bson.M{"$ne": true},
|
||||
}
|
||||
return r.GetCleanedBills(filter)
|
||||
}
|
||||
@@ -311,6 +322,8 @@ func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
|
||||
// 使用聚合管道按月份分组统计
|
||||
// 先按月份和收支类型分组,再汇总
|
||||
pipeline := mongo.Pipeline{
|
||||
// 排除已删除的记录
|
||||
{{Key: "$match", Value: bson.M{"is_deleted": bson.M{"$ne": true}}}},
|
||||
// 添加月份字段
|
||||
{{Key: "$addFields", Value: bson.D{
|
||||
{Key: "month", Value: bson.D{
|
||||
@@ -383,6 +396,108 @@ func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
|
||||
func (r *Repository) UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error) {
|
||||
if r.cleanedCollection == nil {
|
||||
return nil, fmt.Errorf("cleaned collection not initialized")
|
||||
}
|
||||
|
||||
oid, err := primitive.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid id: %w", err)
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return nil, fmt.Errorf("no updates")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := bson.M{"_id": oid}
|
||||
update := bson.M{"$set": updates}
|
||||
opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
|
||||
|
||||
var updated model.CleanedBill
|
||||
err = r.cleanedCollection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updated)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("update bill failed: %w", err)
|
||||
}
|
||||
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
// DeleteCleanedBillByID 按 ID 软删除清洗后的账单(设置 is_deleted = true)
|
||||
func (r *Repository) DeleteCleanedBillByID(id string) error {
|
||||
if r.cleanedCollection == nil {
|
||||
return fmt.Errorf("cleaned collection not initialized")
|
||||
}
|
||||
|
||||
oid, err := primitive.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid id: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := bson.M{"_id": oid}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"is_deleted": true,
|
||||
"updated_at": time.Now(), // 记录更新时间
|
||||
},
|
||||
}
|
||||
result, err := r.cleanedCollection.UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft delete bill failed: %w", err)
|
||||
}
|
||||
|
||||
if result.MatchedCount == 0 {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDeleteJDRelatedBills 软删除描述中包含"京东-订单编号"的非京东账单
|
||||
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
|
||||
func (r *Repository) SoftDeleteJDRelatedBills() (int64, error) {
|
||||
if r.cleanedCollection == nil {
|
||||
return 0, fmt.Errorf("cleaned collection not initialized")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 筛选条件:
|
||||
// 1. 账单类型不是 jd(只处理微信、支付宝等其他来源)
|
||||
// 2. 描述中包含"京东-订单编号"
|
||||
// 3. 尚未被删除
|
||||
filter := bson.M{
|
||||
"bill_type": bson.M{"$ne": "jd"},
|
||||
"description": bson.M{"$regex": "京东-订单编号", "$options": ""},
|
||||
"is_deleted": bson.M{"$ne": true},
|
||||
}
|
||||
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"is_deleted": true,
|
||||
"updated_at": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := r.cleanedCollection.UpdateMany(ctx, filter, update)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("soft delete JD related bills failed: %w", err)
|
||||
}
|
||||
|
||||
return result.ModifiedCount, nil
|
||||
}
|
||||
|
||||
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||
func (r *Repository) GetClient() *mongo.Client {
|
||||
return r.client
|
||||
|
||||
@@ -43,6 +43,17 @@ type BillRepository interface {
|
||||
// GetBillsNeedReview 获取需要复核的账单
|
||||
GetBillsNeedReview() ([]model.CleanedBill, error)
|
||||
|
||||
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
|
||||
UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error)
|
||||
|
||||
// DeleteCleanedBillByID 按 ID 删除清洗后的账单
|
||||
DeleteCleanedBillByID(id string) error
|
||||
|
||||
// CountRawByField 按字段统计原始数据数量
|
||||
CountRawByField(fieldName, value string) (int64, error)
|
||||
|
||||
// SoftDeleteJDRelatedBills 软删除描述中包含"京东-订单编号"的非京东账单
|
||||
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
|
||||
// 返回: 删除数量、错误
|
||||
SoftDeleteJDRelatedBills() (int64, error)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"billai-server/handler"
|
||||
"billai-server/middleware"
|
||||
)
|
||||
|
||||
// Config 路由配置参数
|
||||
@@ -45,22 +46,33 @@ func setupAPIRoutes(r *gin.Engine) {
|
||||
api.POST("/auth/login", handler.Login)
|
||||
api.GET("/auth/validate", handler.ValidateToken)
|
||||
|
||||
// 账单上传
|
||||
api.POST("/upload", handler.Upload)
|
||||
// 需要登录的 API
|
||||
authed := api.Group("/")
|
||||
authed.Use(middleware.AuthRequired())
|
||||
{
|
||||
// 账单上传
|
||||
authed.POST("/upload", handler.Upload)
|
||||
|
||||
// 复核相关
|
||||
api.GET("/review", handler.Review)
|
||||
// 复核相关
|
||||
authed.GET("/review", handler.Review)
|
||||
|
||||
// 账单查询
|
||||
api.GET("/bills", handler.ListBills)
|
||||
// 账单查询
|
||||
authed.GET("/bills", handler.ListBills)
|
||||
|
||||
// 手动创建账单
|
||||
api.POST("/bills/manual", handler.CreateManualBills)
|
||||
// 编辑账单
|
||||
authed.POST("/bills/:id", handler.UpdateBill)
|
||||
|
||||
// 月度统计(全部数据)
|
||||
api.GET("/monthly-stats", handler.MonthlyStats)
|
||||
// 删除账单(软删除)
|
||||
authed.POST("/bills/:id/delete", handler.DeleteBill)
|
||||
|
||||
// 待复核数据统计
|
||||
api.GET("/review-stats", handler.ReviewStats)
|
||||
// 手动创建账单
|
||||
authed.POST("/bills/manual", handler.CreateManualBills)
|
||||
|
||||
// 月度统计(全部数据)
|
||||
authed.GET("/monthly-stats", handler.MonthlyStats)
|
||||
|
||||
// 待复核数据统计
|
||||
authed.GET("/review-stats", handler.ReviewStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
161
server/service/archive.go
Normal file
161
server/service/archive.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yeka/zip"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// ExtractResult 解压结果
|
||||
type ExtractResult struct {
|
||||
ExtractedFiles []string // 解压出的文件路径
|
||||
BillFile string // 账单文件路径(csv 或 xlsx)
|
||||
BillType string // 检测到的账单类型
|
||||
}
|
||||
|
||||
// ExtractZip 解压 ZIP 文件,支持密码
|
||||
// 返回解压后的账单文件路径
|
||||
func ExtractZip(zipPath, destDir, password string) (*ExtractResult, error) {
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法打开 ZIP 文件: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
result := &ExtractResult{
|
||||
ExtractedFiles: make([]string, 0),
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
|
||||
for _, file := range reader.File {
|
||||
// 处理文件名编码(可能是 GBK)
|
||||
fileName := decodeFileName(file.Name)
|
||||
|
||||
// 安全检查:防止路径遍历
|
||||
if strings.Contains(fileName, "..") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
|
||||
// 生成安全的目标文件名(避免编码问题)
|
||||
// 使用时间戳+序号+扩展名的格式
|
||||
safeFileName := fmt.Sprintf("extracted_%s_%d%s", timestamp, len(result.ExtractedFiles), ext)
|
||||
destPath := filepath.Join(destDir, safeFileName)
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
os.MkdirAll(destPath, 0755)
|
||||
continue
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置密码(如果有)
|
||||
if file.IsEncrypted() {
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("ZIP 文件已加密,请提供密码")
|
||||
}
|
||||
file.SetPassword(password)
|
||||
}
|
||||
|
||||
// 打开文件
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
if file.IsEncrypted() {
|
||||
return nil, fmt.Errorf("密码错误或无法解密文件")
|
||||
}
|
||||
return nil, fmt.Errorf("无法读取文件 %s: %w", fileName, err)
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return nil, fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(destFile, rc)
|
||||
rc.Close()
|
||||
destFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
result.ExtractedFiles = append(result.ExtractedFiles, destPath)
|
||||
|
||||
// 检测账单文件
|
||||
if ext == ".csv" || ext == ".xlsx" {
|
||||
result.BillFile = destPath
|
||||
|
||||
// 检测账单类型(从原始文件名检测)
|
||||
if strings.Contains(fileName, "支付宝") || strings.Contains(strings.ToLower(fileName), "alipay") {
|
||||
result.BillType = "alipay"
|
||||
} else if strings.Contains(fileName, "微信") || strings.Contains(strings.ToLower(fileName), "wechat") {
|
||||
result.BillType = "wechat"
|
||||
} else if strings.Contains(fileName, "京东") || strings.Contains(strings.ToLower(fileName), "jd") {
|
||||
result.BillType = "jd"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.BillFile == "" {
|
||||
return nil, fmt.Errorf("ZIP 文件中未找到账单文件(.csv 或 .xlsx)")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decodeFileName 尝试将 GBK 编码的文件名转换为 UTF-8
|
||||
func decodeFileName(name string) string {
|
||||
// 如果文件名只包含 ASCII 字符,直接返回
|
||||
isAscii := true
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] > 127 {
|
||||
isAscii = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isAscii {
|
||||
return name
|
||||
}
|
||||
|
||||
// 尝试 GBK 解码
|
||||
// Windows 上创建的 ZIP 文件通常使用 GBK 编码中文文件名
|
||||
decoded, _, err := transform.String(simplifiedchinese.GBK.NewDecoder(), name)
|
||||
if err == nil && len(decoded) > 0 {
|
||||
return decoded
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// IsSupportedArchive 检查文件是否为支持的压缩格式
|
||||
func IsSupportedArchive(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.HasSuffix(lower, ".zip")
|
||||
}
|
||||
|
||||
// IsBillFile 检查文件是否为账单文件
|
||||
func IsBillFile(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.HasSuffix(lower, ".csv") || strings.HasSuffix(lower, ".xlsx")
|
||||
}
|
||||
|
||||
// CleanupExtractedFiles 清理解压的临时文件
|
||||
func CleanupExtractedFiles(files []string) {
|
||||
for _, f := range files {
|
||||
os.Remove(f)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"billai-server/model"
|
||||
"billai-server/repository"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -9,11 +10,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
|
||||
"billai-server/database"
|
||||
"billai-server/model"
|
||||
)
|
||||
|
||||
// SaveResult 存储结果
|
||||
@@ -23,29 +19,8 @@ type SaveResult struct {
|
||||
DuplicateCount int // 重复数据跳过数量
|
||||
}
|
||||
|
||||
// checkDuplicate 检查记录是否重复
|
||||
// 优先使用 transaction_id 判断,如果为空则使用 时间+金额+商户 组合判断
|
||||
func checkDuplicate(ctx context.Context, bill *model.CleanedBill) bool {
|
||||
var filter bson.M
|
||||
|
||||
if bill.TransactionID != "" {
|
||||
// 优先用交易订单号判断
|
||||
filter = bson.M{"transaction_id": bill.TransactionID}
|
||||
} else {
|
||||
// 回退到 时间+金额+商户 组合判断
|
||||
filter = bson.M{
|
||||
"time": bill.Time.Time(), // 转换为 time.Time 用于 MongoDB 查询
|
||||
"amount": bill.Amount,
|
||||
"merchant": bill.Merchant,
|
||||
}
|
||||
}
|
||||
|
||||
count, err := database.CleanedBillCollection.CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
return false // 查询出错时不认为是重复
|
||||
}
|
||||
|
||||
return count > 0
|
||||
func getRepo() repository.BillRepository {
|
||||
return repository.GetRepository()
|
||||
}
|
||||
|
||||
// DeduplicateResult 去重结果
|
||||
@@ -60,6 +35,11 @@ type DeduplicateResult struct {
|
||||
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
|
||||
// 如果全部重复,返回错误
|
||||
func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error) {
|
||||
repo := getRepo()
|
||||
if repo == nil {
|
||||
return nil, fmt.Errorf("数据库未连接")
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开文件失败: %w", err)
|
||||
@@ -67,6 +47,7 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1 // 允许变长记录
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
@@ -94,10 +75,6 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 创建上下文
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 检查每行是否重复
|
||||
var newRows [][]string
|
||||
for _, row := range dataRows {
|
||||
@@ -112,17 +89,14 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
count, err := database.RawBillCollection.CountDocuments(ctx, bson.M{
|
||||
"raw_data." + header[idFieldIdx]: transactionID,
|
||||
})
|
||||
isDup, err := repo.CheckRawDuplicate(header[idFieldIdx], transactionID)
|
||||
if err != nil {
|
||||
// 查询出错,保留该行
|
||||
newRows = append(newRows, row)
|
||||
continue
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
if !isDup {
|
||||
// 不重复,保留
|
||||
newRows = append(newRows, row)
|
||||
} else {
|
||||
@@ -176,6 +150,10 @@ func detectBillTypeAndIdField(header []string) (billType string, idFieldIdx int)
|
||||
if col == "交易类型" || col == "金额(元)" {
|
||||
billType = "wechat"
|
||||
}
|
||||
// 京东特征
|
||||
if col == "商户名称" || col == "交易说明" {
|
||||
billType = "jd"
|
||||
}
|
||||
|
||||
// 查找去重字段(优先使用交易订单号/交易号)
|
||||
if col == "交易订单号" || col == "交易号" || col == "交易单号" {
|
||||
@@ -198,6 +176,11 @@ func detectBillTypeAndIdField(header []string) (billType string, idFieldIdx int)
|
||||
|
||||
// SaveRawBillsFromFile 从原始上传文件读取数据并存入原始数据集合
|
||||
func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (int, error) {
|
||||
repo := getRepo()
|
||||
if repo == nil {
|
||||
return 0, fmt.Errorf("数据库未连接")
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("打开文件失败: %w", err)
|
||||
@@ -205,6 +188,7 @@ func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (i
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1 // 允许变长记录
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
@@ -219,7 +203,7 @@ func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (i
|
||||
now := time.Now()
|
||||
|
||||
// 构建原始数据文档
|
||||
var rawBills []interface{}
|
||||
var rawBills []model.RawBill
|
||||
for rowIdx, row := range rows[1:] {
|
||||
rawData := make(map[string]interface{})
|
||||
for colIdx, col := range header {
|
||||
@@ -244,16 +228,7 @@ func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (i
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 批量插入原始数据集合
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := database.RawBillCollection.InsertMany(ctx, rawBills)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("插入原始数据失败: %w", err)
|
||||
}
|
||||
|
||||
return len(result.InsertedIDs), nil
|
||||
return repo.SaveRawBills(rawBills)
|
||||
}
|
||||
|
||||
// SaveCleanedBillsFromFile 从清洗后的文件读取数据并存入清洗后数据集合
|
||||
@@ -268,6 +243,11 @@ func SaveCleanedBillsFromFile(filePath, format, billType, sourceFile, uploadBatc
|
||||
// saveCleanedBillsFromCSV 从 CSV 文件读取并存储清洗后账单
|
||||
// 返回: (插入数量, 重复跳过数量, 错误)
|
||||
func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) (int, int, error) {
|
||||
repo := getRepo()
|
||||
if repo == nil {
|
||||
return 0, 0, fmt.Errorf("数据库未连接")
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("打开文件失败: %w", err)
|
||||
@@ -275,6 +255,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1 // 允许变长记录
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
@@ -291,13 +272,8 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
|
||||
colIdx[col] = i
|
||||
}
|
||||
|
||||
// 创建上下文用于去重检查
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 解析数据行
|
||||
var bills []interface{}
|
||||
duplicateCount := 0
|
||||
var bills []model.CleanedBill
|
||||
now := time.Now()
|
||||
|
||||
for _, row := range rows[1:] {
|
||||
@@ -361,31 +337,24 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
|
||||
bill.ReviewLevel = row[idx]
|
||||
}
|
||||
|
||||
// 检查是否重复
|
||||
if checkDuplicate(ctx, &bill) {
|
||||
duplicateCount++
|
||||
continue // 跳过重复记录
|
||||
}
|
||||
|
||||
bills = append(bills, bill)
|
||||
}
|
||||
|
||||
if len(bills) == 0 {
|
||||
return 0, duplicateCount, nil
|
||||
}
|
||||
|
||||
// 批量插入清洗后数据集合
|
||||
result, err := database.CleanedBillCollection.InsertMany(ctx, bills)
|
||||
saved, duplicates, err := repo.SaveCleanedBills(bills)
|
||||
if err != nil {
|
||||
return 0, duplicateCount, fmt.Errorf("插入清洗后数据失败: %w", err)
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return len(result.InsertedIDs), duplicateCount, nil
|
||||
return saved, duplicates, nil
|
||||
}
|
||||
|
||||
// saveCleanedBillsFromJSON 从 JSON 文件读取并存储清洗后账单
|
||||
// 返回: (插入数量, 重复跳过数量, 错误)
|
||||
func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string) (int, int, error) {
|
||||
repo := getRepo()
|
||||
if repo == nil {
|
||||
return 0, 0, fmt.Errorf("数据库未连接")
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("打开文件失败: %w", err)
|
||||
@@ -402,13 +371,8 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
// 创建上下文用于去重检查
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 解析数据
|
||||
var bills []interface{}
|
||||
duplicateCount := 0
|
||||
var bills []model.CleanedBill
|
||||
now := time.Now()
|
||||
|
||||
for _, item := range data {
|
||||
@@ -467,25 +431,14 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string
|
||||
bill.ReviewLevel = v
|
||||
}
|
||||
|
||||
// 检查是否重复
|
||||
if checkDuplicate(ctx, &bill) {
|
||||
duplicateCount++
|
||||
continue // 跳过重复记录
|
||||
}
|
||||
|
||||
bills = append(bills, bill)
|
||||
}
|
||||
|
||||
if len(bills) == 0 {
|
||||
return 0, duplicateCount, nil
|
||||
}
|
||||
|
||||
result, err := database.CleanedBillCollection.InsertMany(ctx, bills)
|
||||
saved, duplicates, err := repo.SaveCleanedBills(bills)
|
||||
if err != nil {
|
||||
return 0, duplicateCount, fmt.Errorf("插入清洗后数据失败: %w", err)
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return len(result.InsertedIDs), duplicateCount, nil
|
||||
return saved, duplicates, nil
|
||||
}
|
||||
|
||||
// parseTime 解析时间字符串
|
||||
@@ -559,106 +512,3 @@ func parseAmount(s string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetCleanedBillsByBatch 根据批次获取清洗后账单
|
||||
func GetCleanedBillsByBatch(uploadBatch string) ([]model.CleanedBill, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := database.CleanedBillCollection.Find(ctx, bson.M{"upload_batch": uploadBatch})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询失败: %w", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var bills []model.CleanedBill
|
||||
if err := cursor.All(ctx, &bills); err != nil {
|
||||
return nil, fmt.Errorf("解析结果失败: %w", err)
|
||||
}
|
||||
|
||||
return bills, nil
|
||||
}
|
||||
|
||||
// GetRawBillsByBatch 根据批次获取原始账单
|
||||
func GetRawBillsByBatch(uploadBatch string) ([]model.RawBill, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := database.RawBillCollection.Find(ctx, bson.M{"upload_batch": uploadBatch})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询失败: %w", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var bills []model.RawBill
|
||||
if err := cursor.All(ctx, &bills); err != nil {
|
||||
return nil, fmt.Errorf("解析结果失败: %w", err)
|
||||
}
|
||||
|
||||
return bills, nil
|
||||
}
|
||||
|
||||
// GetBillStats 获取账单统计信息
|
||||
func GetBillStats() (map[string]interface{}, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 原始数据总数
|
||||
rawTotal, err := database.RawBillCollection.CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清洗后数据总数
|
||||
cleanedTotal, err := database.CleanedBillCollection.CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 支出总额(从清洗后数据统计)
|
||||
expensePipeline := []bson.M{
|
||||
{"$match": bson.M{"income_expense": "支出"}},
|
||||
{"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}},
|
||||
}
|
||||
expenseCursor, err := database.CleanedBillCollection.Aggregate(ctx, expensePipeline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer expenseCursor.Close(ctx)
|
||||
|
||||
var expenseResult []bson.M
|
||||
expenseCursor.All(ctx, &expenseResult)
|
||||
totalExpense := 0.0
|
||||
if len(expenseResult) > 0 {
|
||||
if v, ok := expenseResult[0]["total"].(float64); ok {
|
||||
totalExpense = v
|
||||
}
|
||||
}
|
||||
|
||||
// 收入总额(从清洗后数据统计)
|
||||
incomePipeline := []bson.M{
|
||||
{"$match": bson.M{"income_expense": "收入"}},
|
||||
{"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}},
|
||||
}
|
||||
incomeCursor, err := database.CleanedBillCollection.Aggregate(ctx, incomePipeline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer incomeCursor.Close(ctx)
|
||||
|
||||
var incomeResult []bson.M
|
||||
incomeCursor.All(ctx, &incomeResult)
|
||||
totalIncome := 0.0
|
||||
if len(incomeResult) > 0 {
|
||||
if v, ok := incomeResult[0]["total"].(float64); ok {
|
||||
totalIncome = v
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"raw_records": rawTotal,
|
||||
"cleaned_records": cleanedTotal,
|
||||
"total_expense": totalExpense,
|
||||
"total_income": totalIncome,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanRes
|
||||
return cleaner.Clean(inputPath, outputPath, opts)
|
||||
}
|
||||
|
||||
// ConvertBillFile 转换账单文件格式(xlsx -> csv,处理编码)
|
||||
// 返回转换后的文件路径和检测到的账单类型
|
||||
func ConvertBillFile(inputPath string) (outputPath string, billType string, err error) {
|
||||
cleaner := adapter.GetCleaner()
|
||||
return cleaner.Convert(inputPath)
|
||||
}
|
||||
|
||||
// DetectBillTypeFromOutput 从脚本输出中检测账单类型
|
||||
// 保留此函数以兼容其他调用
|
||||
func DetectBillTypeFromOutput(output string) string {
|
||||
@@ -29,6 +36,9 @@ func DetectBillTypeFromOutput(output string) string {
|
||||
if containsSubstring(output, "微信") {
|
||||
return "wechat"
|
||||
}
|
||||
if containsSubstring(output, "京东") {
|
||||
return "jd"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ func extractFromCSV(filePath string) []model.ReviewRecord {
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1 // 允许变长记录
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil || len(rows) < 2 {
|
||||
return records
|
||||
@@ -131,4 +132,3 @@ func extractFromJSON(filePath string) []model.ReviewRecord {
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
|
||||
5
web/.dockerignore
Normal file
5
web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.git
|
||||
.DS_Store
|
||||
.svelte-kit
|
||||
build
|
||||
@@ -1,38 +1,34 @@
|
||||
# sv
|
||||
# BillAI Web
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
BillAI 的前端 Web 应用,基于 SvelteKit + Tailwind,提供账单分析/复核/管理等界面。
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
## 开发
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
常用命令:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
yarn check
|
||||
yarn lint
|
||||
yarn format
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
## 构建
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
yarn build
|
||||
yarn preview
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
## API 访问
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
- 开发环境下通过 SvelteKit/Vite 代理访问后端(统一使用相对路径,例如 `/api/...`)
|
||||
- Docker 部署时由 `docker-compose.yaml` 将前端容器与后端容器联通
|
||||
|
||||
## 说明
|
||||
|
||||
前端展示层使用统一账单模型 `UIBill`(camelCase 字段 + `amount:number`),分析链路与详情编辑弹窗避免多种账单类型混用。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "1.0.3",
|
||||
"version": "1.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
3
web/src/app.d.ts
vendored
3
web/src/app.d.ts
vendored
@@ -8,6 +8,9 @@ declare global {
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
// Vite 注入的全局变量
|
||||
const __APP_VERSION__: string;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
|
||||
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
|
||||
const API_BASE = '';
|
||||
|
||||
async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
if (browser) {
|
||||
const token = auth.getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(input, { ...init, headers });
|
||||
|
||||
if (browser && response.status === 401) {
|
||||
// 由后端判断 Token 是否过期/无效,这里只负责清理和退登
|
||||
auth.logout();
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 健康检查
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
@@ -15,7 +42,7 @@ export async function checkHealth(): Promise<boolean> {
|
||||
}
|
||||
|
||||
// 类型定义
|
||||
export type BillType = 'alipay' | 'wechat';
|
||||
export type BillType = 'alipay' | 'wechat' | 'jd';
|
||||
|
||||
export interface UploadData {
|
||||
bill_type: BillType;
|
||||
@@ -69,24 +96,11 @@ export interface MonthlyStatsResponse {
|
||||
data?: MonthlyStat[];
|
||||
}
|
||||
|
||||
export interface BillRecord {
|
||||
time: string;
|
||||
category: string;
|
||||
merchant: string;
|
||||
description: string;
|
||||
income_expense: string;
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
remark: string;
|
||||
needs_review: string;
|
||||
}
|
||||
|
||||
// 上传账单
|
||||
export async function uploadBill(
|
||||
file: File,
|
||||
type: BillType,
|
||||
options?: { year?: number; month?: number }
|
||||
options?: { year?: number; month?: number; password?: string }
|
||||
): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -98,8 +112,11 @@ export async function uploadBill(
|
||||
if (options?.month) {
|
||||
formData.append('month', options.month.toString());
|
||||
}
|
||||
if (options?.password) {
|
||||
formData.append('password', options.password);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/upload`, {
|
||||
const response = await apiFetch(`${API_BASE}/api/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -111,20 +128,9 @@ export async function uploadBill(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取复核记录
|
||||
export async function getReviewRecords(fileName: string): Promise<ReviewResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取月度统计(全部数据,不受筛选条件影响)
|
||||
export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/monthly-stats`);
|
||||
const response = await apiFetch(`${API_BASE}/api/monthly-stats`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -139,8 +145,8 @@ export function getDownloadUrl(fileUrl: string): string {
|
||||
}
|
||||
|
||||
// 解析账单内容(用于前端展示全部记录)
|
||||
export async function fetchBillContent(fileName: string): Promise<BillRecord[]> {
|
||||
const response = await fetch(`${API_BASE}/download/${fileName}`);
|
||||
export async function fetchBillContent(fileName: string): Promise<UIBill[]> {
|
||||
const response = await apiFetch(`${API_BASE}/download/${fileName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -151,11 +157,11 @@ export async function fetchBillContent(fileName: string): Promise<BillRecord[]>
|
||||
}
|
||||
|
||||
// 解析 CSV
|
||||
function parseCSV(text: string): BillRecord[] {
|
||||
function parseCSV(text: string): UIBill[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const records: BillRecord[] = [];
|
||||
const records: UIBill[] = [];
|
||||
|
||||
// CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
@@ -165,13 +171,13 @@ function parseCSV(text: string): BillRecord[] {
|
||||
time: values[0] || '',
|
||||
category: values[1] || '',
|
||||
merchant: values[2] || '',
|
||||
description: values[4] || '', // 跳过 values[3] (对方账号)
|
||||
income_expense: values[5] || '',
|
||||
amount: values[6] || '',
|
||||
payment_method: values[7] || '',
|
||||
description: values[4] || '', // 跳过 values[3] (对方账号)
|
||||
incomeExpense: values[5] || '',
|
||||
amount: Number(values[6] || 0),
|
||||
paymentMethod: values[7] || '',
|
||||
status: values[8] || '',
|
||||
remark: values[11] || '',
|
||||
needs_review: values[13] || '', // 复核等级在第14列
|
||||
reviewLevel: values[13] || '', // 复核等级在第14列
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -223,6 +229,43 @@ export interface CleanedBill {
|
||||
review_level: string;
|
||||
}
|
||||
|
||||
// 更新账单
|
||||
export interface UpdateBillRequest {
|
||||
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 UpdateBillResponse {
|
||||
result: boolean;
|
||||
message?: string;
|
||||
data?: CleanedBill;
|
||||
}
|
||||
|
||||
export async function updateBill(id: string, patch: UpdateBillRequest): Promise<UpdateBillResponse> {
|
||||
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// keep same behavior as other API calls
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 账单列表请求参数
|
||||
export interface FetchBillsParams {
|
||||
page?: number;
|
||||
@@ -264,7 +307,7 @@ export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsRe
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE}/api/bills${queryString ? '?' + queryString : ''}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const response = await apiFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -304,7 +347,7 @@ export interface CreateManualBillsResponse {
|
||||
|
||||
// 批量创建手动账单
|
||||
export async function createManualBills(bills: ManualBillInput[]): Promise<CreateManualBillsResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/bills/manual`, {
|
||||
const response = await apiFetch(`${API_BASE}/api/bills/manual`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -321,7 +364,7 @@ export async function createManualBills(bills: ManualBillInput[]): Promise<Creat
|
||||
|
||||
// 获取待复核数据统计
|
||||
export async function fetchReviewStats(): Promise<ReviewResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/review-stats`);
|
||||
const response = await apiFetch(`${API_BASE}/api/review-stats`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -332,7 +375,7 @@ export async function fetchReviewStats(): Promise<ReviewResponse> {
|
||||
|
||||
// 获取所有待复核的账单(完整数据)
|
||||
export async function fetchBillsByReviewLevel(): Promise<BillsResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/bills?page=1&page_size=1000&review_level=HIGH,LOW`);
|
||||
const response = await apiFetch(`${API_BASE}/api/bills?page=1&page_size=1000&review_level=HIGH,LOW`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -340,3 +383,22 @@ export async function fetchBillsByReviewLevel(): Promise<BillsResponse> {
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 删除账单响应
|
||||
export interface DeleteBillResponse {
|
||||
result: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// 删除账单(软删除)
|
||||
export async function deleteBill(id: string): Promise<DeleteBillResponse> {
|
||||
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}/delete`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
129
web/src/lib/components/ChangelogDrawer.svelte
Normal file
129
web/src/lib/components/ChangelogDrawer.svelte
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
|
||||
let { open = $bindable(false) } = $props();
|
||||
|
||||
// Changelog 内容(从 CHANGELOG.md 解析或硬编码)
|
||||
const changelog = [
|
||||
{
|
||||
version: '1.3.1',
|
||||
date: '2026-01-26',
|
||||
changes: {
|
||||
优化: [
|
||||
'版本号显示优化 - 侧边栏版本号按钮样式改进',
|
||||
'移至次级导航区域,与其他菜单项样式一致',
|
||||
'更新日志改用 Sheet 组件(右侧滑出),替代底部 Drawer',
|
||||
'统一暗色主题下的视觉效果'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '1.3.0',
|
||||
date: '2026-01-26',
|
||||
changes: {
|
||||
新增: [
|
||||
'京东账单支持 - 支持京东白条账单上传和清洗',
|
||||
'自动识别京东账单类型(交易流水 ZIP)',
|
||||
'解析京东白条账单 CSV 格式(含还款日期信息)',
|
||||
'京东专属分类映射配置',
|
||||
'支持京东外卖、京东平台商户等商户识别',
|
||||
'上传页面和账单列表页面添加"京东"选项'
|
||||
],
|
||||
优化: [
|
||||
'京东订单智能去重 - 上传京东账单时自动软删除其他来源中的京东订单',
|
||||
'分类推断复核等级优化 - 京东账单引入 LOW 复核等级',
|
||||
'京东平台商户关键词扩展'
|
||||
],
|
||||
技术改进: [
|
||||
'新增京东账单清理器',
|
||||
'新增京东专属配置',
|
||||
'后端新增软删除接口',
|
||||
'新增单元测试(11 个测试用例)'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '1.2.1',
|
||||
date: '2026-01-23',
|
||||
changes: {
|
||||
优化: [
|
||||
'智能复核快捷确认 - 在复核列表每行添加快捷确认按钮',
|
||||
'无需打开详情页面即可确认分类正确',
|
||||
'自动更新统计数据',
|
||||
'提升复核效率,支持快速批量确认'
|
||||
],
|
||||
文档: ['AGENTS.md 更新 - 精简为 150 行,专为 AI 编程助手设计']
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '1.2.0',
|
||||
date: '2026-01-25',
|
||||
changes: {
|
||||
新增: [
|
||||
'账单删除功能 - 支持在账单详情抽屉中删除账单(软删除)',
|
||||
'删除按钮带二次确认,防止误操作',
|
||||
'已删除的账单在所有查询中自动过滤'
|
||||
],
|
||||
技术改进: [
|
||||
'后端 MongoDB 查询方法添加软删除过滤',
|
||||
'新增 DELETE /api/bills/:id 接口'
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[500px] overflow-hidden">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title class="text-xl font-semibold">版本更新日志</Sheet.Title>
|
||||
<Sheet.Description class="text-muted-foreground">
|
||||
查看 BillAI 的版本更新历史
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-6">
|
||||
<div class="space-y-8">
|
||||
{#each changelog as release}
|
||||
<div class="space-y-3">
|
||||
<!-- 版本号和日期 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">v{release.version}</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar class="size-4" />
|
||||
<span>{release.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变更内容 -->
|
||||
<div class="space-y-4 pl-7 border-l-2 border-muted">
|
||||
{#each Object.entries(release.changes) as [category, items]}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-primary">{category}</h4>
|
||||
<ul class="space-y-1.5 text-sm text-muted-foreground">
|
||||
{#each items as item}
|
||||
<li class="flex gap-2 leading-relaxed">
|
||||
<span class="text-primary mt-1.5">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet.Footer class="border-t pt-4">
|
||||
<Button variant="outline" onclick={() => (open = false)} class="w-full">关闭</Button>
|
||||
</Sheet.Footer>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
368
web/src/lib/components/analysis/BillDetailDrawer.svelte
Normal file
368
web/src/lib/components/analysis/BillDetailDrawer.svelte
Normal file
@@ -0,0 +1,368 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import Receipt from '@lucide/svelte/icons/receipt';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Store from '@lucide/svelte/icons/store';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import FileText from '@lucide/svelte/icons/file-text';
|
||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||
|
||||
import { updateBill, deleteBill } from '$lib/api';
|
||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
record?: UIBill | null;
|
||||
categories?: string[];
|
||||
|
||||
title?: string;
|
||||
viewDescription?: string;
|
||||
editDescription?: string;
|
||||
|
||||
titleExtra?: Snippet<[{ isEditing: boolean }]>;
|
||||
|
||||
contentClass?: string;
|
||||
|
||||
/** 保存时是否清除 review_level(用于复核场景) */
|
||||
clearReviewLevel?: boolean;
|
||||
|
||||
/** 是否允许删除 */
|
||||
allowDelete?: boolean;
|
||||
|
||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||
onDelete?: (deleted: UIBill) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
record = $bindable<UIBill | null>(null),
|
||||
categories = [],
|
||||
title = '账单详情',
|
||||
viewDescription = '查看这笔支出的完整信息',
|
||||
editDescription = '修改这笔支出的信息',
|
||||
titleExtra,
|
||||
contentClass,
|
||||
clearReviewLevel = false,
|
||||
allowDelete = false,
|
||||
onUpdate,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
|
||||
let isEditing = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let isConfirming = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
let editForm = $state({
|
||||
amount: '',
|
||||
merchant: '',
|
||||
category: '',
|
||||
description: '',
|
||||
payment_method: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
isEditing = false;
|
||||
showDeleteConfirm = false;
|
||||
});
|
||||
|
||||
function startEdit() {
|
||||
if (!record) return;
|
||||
editForm = {
|
||||
amount: String(record.amount),
|
||||
merchant: record.merchant,
|
||||
category: record.category,
|
||||
description: record.description || '',
|
||||
payment_method: record.paymentMethod || ''
|
||||
};
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function handleCategoryChange(value: string | undefined) {
|
||||
if (value) editForm.category = value;
|
||||
}
|
||||
|
||||
// 确认正确(仅清除 review_level,不修改其他字段)
|
||||
async function confirmCorrect() {
|
||||
if (!record || isConfirming) return;
|
||||
|
||||
isConfirming = true;
|
||||
const original = { ...record };
|
||||
|
||||
try {
|
||||
const billId = record.id;
|
||||
if (billId) {
|
||||
const resp = await updateBill(billId, { review_level: '' });
|
||||
|
||||
if (resp.result && resp.data) {
|
||||
const updated = cleanedBillToUIBill(resp.data);
|
||||
record = updated;
|
||||
onUpdate?.(updated, original);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isConfirming = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除账单
|
||||
async function handleDelete() {
|
||||
if (!record || isDeleting) return;
|
||||
|
||||
isDeleting = true;
|
||||
const deleted = { ...record };
|
||||
|
||||
try {
|
||||
const billId = record.id;
|
||||
if (billId) {
|
||||
const resp = await deleteBill(billId);
|
||||
|
||||
if (resp.result) {
|
||||
open = false;
|
||||
onDelete?.(deleted);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!record) return;
|
||||
if (isSaving) return;
|
||||
|
||||
isSaving = true;
|
||||
const original = { ...record };
|
||||
|
||||
const updated: UIBill = {
|
||||
...record,
|
||||
amount: Number(editForm.amount),
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
description: editForm.description,
|
||||
paymentMethod: editForm.payment_method
|
||||
};
|
||||
|
||||
try {
|
||||
const billId = (record as unknown as { id?: string }).id;
|
||||
if (billId) {
|
||||
const resp = await updateBill(billId, {
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
amount: Number(editForm.amount),
|
||||
description: editForm.description,
|
||||
pay_method: editForm.payment_method,
|
||||
// 复核模式下清除 review_level
|
||||
...(clearReviewLevel ? { review_level: '' } : {})
|
||||
});
|
||||
|
||||
if (resp.result && resp.data) {
|
||||
const persisted = cleanedBillToUIBill(resp.data);
|
||||
updated.id = persisted.id;
|
||||
updated.amount = persisted.amount;
|
||||
updated.merchant = persisted.merchant;
|
||||
updated.category = persisted.category;
|
||||
updated.description = persisted.description;
|
||||
updated.paymentMethod = persisted.paymentMethod;
|
||||
updated.time = persisted.time;
|
||||
updated.incomeExpense = persisted.incomeExpense;
|
||||
updated.status = persisted.status;
|
||||
updated.remark = persisted.remark;
|
||||
updated.reviewLevel = persisted.reviewLevel;
|
||||
}
|
||||
}
|
||||
|
||||
record = updated;
|
||||
isEditing = false;
|
||||
onUpdate?.(updated, original);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer.Root bind:open>
|
||||
<Drawer.Content class={`md:max-w-4xl ${contentClass ?? ''}`.trim()}>
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : title}
|
||||
{@render titleExtra?.({ isEditing })}
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{isEditing ? editDescription : viewDescription}
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto px-4 py-4 md:px-0">
|
||||
{#if record}
|
||||
{#if isEditing}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>金额</Label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
||||
<Input type="number" bind:value={editForm.amount} class="pl-8" step="0.01" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>商家</Label>
|
||||
<Input bind:value={editForm.merchant} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>分类</Label>
|
||||
{#if categories.length > 0}
|
||||
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span>{editForm.category || '选择分类'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content>
|
||||
{#each categories as category}
|
||||
<Select.Item value={category}>{category}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<Input bind:value={editForm.category} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input bind:value={editForm.description} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>支付方式</Label>
|
||||
<Input bind:value={editForm.payment_method} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
|
||||
¥{record.amount.toFixed(2)}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Store class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">商家</div>
|
||||
<div class="font-medium truncate">{record.merchant}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Tag class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">分类</div>
|
||||
<div class="font-medium">{record.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Calendar class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">时间</div>
|
||||
<div class="font-medium">{record.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if record.description}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<FileText class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">描述</div>
|
||||
<div class="font-medium">{record.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if record.paymentMethod}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<CreditCard class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">支付方式</div>
|
||||
<div class="font-medium">{record.paymentMethod}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Drawer.Footer>
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit} disabled={isSaving}>
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{isSaving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
{:else if showDeleteConfirm}
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<span class="text-sm text-muted-foreground">确定要删除这条账单吗?</span>
|
||||
<div class="flex-1"></div>
|
||||
<Button variant="outline" onclick={() => (showDeleteConfirm = false)} disabled={isDeleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={handleDelete} disabled={isDeleting}>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
{isDeleting ? '删除中…' : '确认删除'}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if allowDelete}
|
||||
<Button variant="outline" class="text-red-600 hover:text-red-700 hover:bg-red-50" onclick={() => (showDeleteConfirm = true)}>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
<Button variant="outline" onclick={() => (open = false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{#if clearReviewLevel}
|
||||
<Button class="bg-green-600 hover:bg-green-700 text-white" onclick={confirmCorrect} disabled={isConfirming}>
|
||||
<Check class="h-4 w-4 mr-2" />
|
||||
{isConfirming ? '确认中…' : '确认正确'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={startEdit}>
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
@@ -1,33 +1,22 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Receipt from '@lucide/svelte/icons/receipt';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Store from '@lucide/svelte/icons/store';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import FileText from '@lucide/svelte/icons/file-text';
|
||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import { type UIBill } from '$lib/models/bill';
|
||||
import BillDetailDrawer from './BillDetailDrawer.svelte';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
showCategory?: boolean;
|
||||
showDescription?: boolean;
|
||||
pageSize?: number;
|
||||
categories?: string[];
|
||||
onUpdate?: (updated: BillRecord, original: BillRecord) => void;
|
||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||
onDelete?: (deleted: UIBill) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -36,7 +25,8 @@
|
||||
showDescription = true,
|
||||
pageSize = 10,
|
||||
categories = [],
|
||||
onUpdate
|
||||
onUpdate,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
|
||||
// 排序状态
|
||||
@@ -50,16 +40,7 @@
|
||||
|
||||
// 详情弹窗状态
|
||||
let detailDialogOpen = $state(false);
|
||||
let selectedRecord = $state<BillRecord | null>(null);
|
||||
let selectedIndex = $state(-1);
|
||||
let isEditing = $state(false);
|
||||
let editForm = $state({
|
||||
amount: '',
|
||||
merchant: '',
|
||||
category: '',
|
||||
description: '',
|
||||
payment_method: ''
|
||||
});
|
||||
let selectedRecord = $state<UIBill | null>(null);
|
||||
|
||||
// 排序后的记录
|
||||
let sortedRecords = $derived.by(() => {
|
||||
@@ -79,7 +60,7 @@
|
||||
cmp = (a.description || '').localeCompare(b.description || '');
|
||||
break;
|
||||
case 'amount':
|
||||
cmp = parseFloat(a.amount) - parseFloat(b.amount);
|
||||
cmp = (a.amount || 0) - (b.amount || 0);
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
@@ -109,68 +90,46 @@
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
function openDetail(record: BillRecord, index: number) {
|
||||
function openDetail(record: UIBill) {
|
||||
selectedRecord = record;
|
||||
selectedIndex = index;
|
||||
isEditing = false;
|
||||
detailDialogOpen = true;
|
||||
}
|
||||
|
||||
// 进入编辑模式
|
||||
function startEdit() {
|
||||
if (!selectedRecord) return;
|
||||
editForm = {
|
||||
amount: selectedRecord.amount,
|
||||
merchant: selectedRecord.merchant,
|
||||
category: selectedRecord.category,
|
||||
description: selectedRecord.description || '',
|
||||
payment_method: selectedRecord.payment_method || ''
|
||||
};
|
||||
isEditing = true;
|
||||
}
|
||||
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||
// 更新本地数据(fallback:按引用/关键字段查找)
|
||||
const idx = records.findIndex(r => r === original);
|
||||
const finalIdx = idx !== -1
|
||||
? idx
|
||||
: records.findIndex(r =>
|
||||
r.time === original.time &&
|
||||
r.merchant === original.merchant &&
|
||||
r.amount === original.amount
|
||||
);
|
||||
|
||||
// 取消编辑
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
function saveEdit() {
|
||||
if (!selectedRecord) return;
|
||||
|
||||
const original = { ...selectedRecord };
|
||||
const updated: BillRecord = {
|
||||
...selectedRecord,
|
||||
amount: editForm.amount,
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
description: editForm.description,
|
||||
payment_method: editForm.payment_method
|
||||
};
|
||||
|
||||
// 更新本地数据
|
||||
const idx = records.findIndex(r =>
|
||||
r.time === selectedRecord!.time &&
|
||||
r.merchant === selectedRecord!.merchant &&
|
||||
r.amount === selectedRecord!.amount
|
||||
);
|
||||
if (idx !== -1) {
|
||||
records[idx] = updated;
|
||||
records = [...records]; // 触发响应式更新
|
||||
if (finalIdx !== -1) {
|
||||
records[finalIdx] = updated;
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
selectedRecord = updated;
|
||||
isEditing = false;
|
||||
|
||||
// 通知父组件
|
||||
|
||||
onUpdate?.(updated, original);
|
||||
}
|
||||
|
||||
// 处理分类选择
|
||||
function handleCategoryChange(value: string | undefined) {
|
||||
if (value) {
|
||||
editForm.category = value;
|
||||
function handleRecordDeleted(deleted: UIBill) {
|
||||
const idx = records.findIndex(r => r === deleted);
|
||||
const finalIdx = idx !== -1
|
||||
? idx
|
||||
: records.findIndex(r =>
|
||||
r.time === deleted.time &&
|
||||
r.merchant === deleted.merchant &&
|
||||
r.amount === deleted.amount
|
||||
);
|
||||
|
||||
if (finalIdx !== -1) {
|
||||
records.splice(finalIdx, 1);
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
onDelete?.(deleted);
|
||||
}
|
||||
|
||||
// 重置分页(当记录变化时)
|
||||
@@ -261,7 +220,7 @@
|
||||
{#each paginatedRecords as record, i}
|
||||
<Table.Row
|
||||
class="hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onclick={() => openDetail(record, (currentPage - 1) * pageSize + i)}
|
||||
onclick={() => openDetail(record)}
|
||||
>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{record.time.substring(0, 16)}
|
||||
@@ -276,7 +235,7 @@
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
¥{record.amount.toFixed(2)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
@@ -333,151 +292,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 详情/编辑弹窗 -->
|
||||
<Drawer.Root bind:open={detailDialogOpen}>
|
||||
<Drawer.Content class="sm:max-w-md">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : '账单详情'}
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
{#if selectedRecord}
|
||||
{#if isEditing}
|
||||
<!-- 编辑表单 -->
|
||||
<div class="space-y-4 py-4 px-4 md:px-0">
|
||||
<div class="space-y-2">
|
||||
<Label>金额</Label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={editForm.amount}
|
||||
class="pl-8"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>商家</Label>
|
||||
<Input bind:value={editForm.merchant} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>分类</Label>
|
||||
{#if categories.length > 0}
|
||||
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span>{editForm.category || '选择分类'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content>
|
||||
{#each categories as category}
|
||||
<Select.Item value={category}>{category}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<Input bind:value={editForm.category} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input bind:value={editForm.description} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>支付方式</Label>
|
||||
<Input bind:value={editForm.payment_method} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 详情展示 -->
|
||||
<div class="py-4 px-4 md:px-0">
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
|
||||
¥{selectedRecord.amount}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
支出金额
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Store class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">商家</div>
|
||||
<div class="font-medium truncate">{selectedRecord.merchant}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Tag class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">分类</div>
|
||||
<div class="font-medium">{selectedRecord.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Calendar class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">时间</div>
|
||||
<div class="font-medium">{selectedRecord.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedRecord.description}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<FileText class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">描述</div>
|
||||
<div class="font-medium">{selectedRecord.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedRecord.payment_method}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<CreditCard class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">支付方式</div>
|
||||
<div class="font-medium">{selectedRecord.payment_method}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Drawer.Footer>
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit}>
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={() => detailDialogOpen = false}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onclick={startEdit}>
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
<BillDetailDrawer
|
||||
bind:open={detailDialogOpen}
|
||||
bind:record={selectedRecord}
|
||||
{categories}
|
||||
title="账单详情"
|
||||
viewDescription="查看这笔支出的详细信息"
|
||||
editDescription="修改这笔支出的信息"
|
||||
onUpdate={handleRecordUpdated}
|
||||
onDelete={handleRecordDeleted}
|
||||
allowDelete={true}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
import { getPercentage } from '$lib/services/analysis';
|
||||
import { barColors } from '$lib/constants/chart';
|
||||
import BillRecordsTable from './BillRecordsTable.svelte';
|
||||
@@ -14,11 +14,27 @@
|
||||
categoryStats: CategoryStat[];
|
||||
pieChartData: PieChartDataItem[];
|
||||
totalExpense: number;
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
categories?: string[];
|
||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||
}
|
||||
|
||||
let { categoryStats, pieChartData, totalExpense, records = $bindable(), categories = [] }: Props = $props();
|
||||
let { categoryStats, pieChartData, totalExpense, records = $bindable(), categories = [], onUpdate }: Props = $props();
|
||||
|
||||
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||
// 更新本地 records 数组
|
||||
const idx = records.findIndex(r =>
|
||||
r === original ||
|
||||
(r.time === original.time && r.merchant === original.merchant && r.amount === original.amount)
|
||||
);
|
||||
if (idx !== -1) {
|
||||
records[idx] = updated;
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
// 传播到父组件
|
||||
onUpdate?.(updated, original);
|
||||
}
|
||||
|
||||
let mode = $state<'bar' | 'pie'>('bar');
|
||||
let dialogOpen = $state(false);
|
||||
@@ -59,7 +75,7 @@
|
||||
// 获取选中分类的账单记录
|
||||
let selectedRecords = $derived.by(() => {
|
||||
if (!selectedCategory) return [];
|
||||
return records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
|
||||
return records.filter(r => r.category === selectedCategory && r.incomeExpense === '支出');
|
||||
});
|
||||
|
||||
// 选中分类的统计
|
||||
@@ -199,7 +215,7 @@
|
||||
|
||||
<!-- 分类详情弹窗 -->
|
||||
<Drawer.Root bind:open={dialogOpen}>
|
||||
<Drawer.Content class="sm:max-w-4xl">
|
||||
<Drawer.Content class="md:max-w-4xl">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<PieChartIcon class="h-5 w-5" />
|
||||
@@ -213,7 +229,7 @@
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto px-4 md:px-0">
|
||||
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} />
|
||||
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} onUpdate={handleRecordUpdated} />
|
||||
</div>
|
||||
|
||||
<Drawer.Footer>
|
||||
|
||||
@@ -9,22 +9,71 @@
|
||||
import AreaChart from '@lucide/svelte/icons/area-chart';
|
||||
import LineChart from '@lucide/svelte/icons/line-chart';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
import { formatLocalDate } from '$lib/utils';
|
||||
import BillRecordsTable from './BillRecordsTable.svelte';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
categories?: string[];
|
||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||
onDelete?: (deleted: UIBill) => void;
|
||||
}
|
||||
|
||||
let { records = $bindable(), categories = [] }: Props = $props();
|
||||
let { records = $bindable(), categories = [], onUpdate, onDelete }: Props = $props();
|
||||
|
||||
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||
// 更新 records 数组
|
||||
const idx = records.findIndex(r =>
|
||||
r === original ||
|
||||
(r.time === original.time && r.merchant === original.merchant && r.amount === original.amount)
|
||||
);
|
||||
if (idx !== -1) {
|
||||
records[idx] = updated;
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
// 更新 selectedDateRecords(如果账单在当前选中的日期记录中)
|
||||
const dateIdx = selectedDateRecords.findIndex(r =>
|
||||
r === original ||
|
||||
(r.time === original.time && r.merchant === original.merchant && r.amount === original.amount)
|
||||
);
|
||||
if (dateIdx !== -1) {
|
||||
selectedDateRecords[dateIdx] = updated;
|
||||
selectedDateRecords = [...selectedDateRecords];
|
||||
}
|
||||
|
||||
// 传播到父组件
|
||||
onUpdate?.(updated, original);
|
||||
}
|
||||
|
||||
function handleRecordDeleted(deleted: UIBill) {
|
||||
const idx = records.findIndex(r =>
|
||||
r === deleted ||
|
||||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||
);
|
||||
if (idx !== -1) {
|
||||
records.splice(idx, 1);
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
const dateIdx = selectedDateRecords.findIndex(r =>
|
||||
r === deleted ||
|
||||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||
);
|
||||
if (dateIdx !== -1) {
|
||||
selectedDateRecords.splice(dateIdx, 1);
|
||||
selectedDateRecords = [...selectedDateRecords];
|
||||
}
|
||||
|
||||
onDelete?.(deleted);
|
||||
}
|
||||
|
||||
// Dialog 状态
|
||||
let dialogOpen = $state(false);
|
||||
let selectedDate = $state<Date | null>(null);
|
||||
let selectedDateRecords = $state<BillRecord[]>([]);
|
||||
let selectedDateRecords = $state<UIBill[]>([]);
|
||||
|
||||
// 时间范围选项
|
||||
type TimeRange = '7d' | 'week' | '30d' | 'month' | '3m' | 'year';
|
||||
@@ -125,7 +174,7 @@
|
||||
|
||||
// 过滤支出记录
|
||||
const expenseRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
if (r.incomeExpense !== '支出') return false;
|
||||
const recordDate = new Date(extractDateStr(r.time));
|
||||
return recordDate >= cutoffDate;
|
||||
});
|
||||
@@ -139,7 +188,7 @@
|
||||
expenseRecords.forEach(record => {
|
||||
const dateStr = extractDateStr(record.time);
|
||||
const category = record.category || '其他';
|
||||
const amount = parseFloat(record.amount) || 0;
|
||||
const amount = record.amount || 0;
|
||||
|
||||
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
|
||||
|
||||
@@ -502,6 +551,19 @@
|
||||
tooltipData = null;
|
||||
}
|
||||
|
||||
function openDateDetails(clickedDate: Date) {
|
||||
const dateStr = formatLocalDate(clickedDate);
|
||||
|
||||
selectedDate = clickedDate;
|
||||
selectedDateRecords = records.filter(r => {
|
||||
if (r.incomeExpense !== '支出') return false;
|
||||
const recordDateStr = extractDateStr(r.time);
|
||||
return recordDateStr === dateStr;
|
||||
});
|
||||
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
// 点击打开 Dialog
|
||||
function handleClick(event: MouseEvent, data: any[], maxValue: number) {
|
||||
if (data.length === 0) return;
|
||||
@@ -527,17 +589,7 @@
|
||||
|
||||
// 点击图表任意位置都触发,选择最近的日期
|
||||
const clickedDate = data[closestIdx].date;
|
||||
const dateStr = formatLocalDate(clickedDate);
|
||||
|
||||
// 找出当天的所有支出记录
|
||||
selectedDate = clickedDate;
|
||||
selectedDateRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
const recordDateStr = extractDateStr(r.time);
|
||||
return recordDateStr === dateStr;
|
||||
});
|
||||
|
||||
dialogOpen = true;
|
||||
openDateDetails(clickedDate);
|
||||
}
|
||||
|
||||
// 计算选中日期的统计
|
||||
@@ -549,7 +601,7 @@
|
||||
|
||||
selectedDateRecords.forEach(r => {
|
||||
const cat = r.category || '其他';
|
||||
const amount = parseFloat(r.amount) || 0;
|
||||
const amount = r.amount || 0;
|
||||
total += amount;
|
||||
|
||||
if (!categoryMap.has(cat)) {
|
||||
@@ -648,16 +700,22 @@
|
||||
|
||||
<!-- 趋势图 (自定义 SVG) -->
|
||||
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<svg
|
||||
viewBox="0 0 {chartWidth} {chartHeight}"
|
||||
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
||||
role="application"
|
||||
role="button"
|
||||
aria-label="每日支出趋势图表,点击可查看当日详情"
|
||||
tabindex="-1"
|
||||
tabindex="0"
|
||||
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
|
||||
onmouseleave={handleMouseLeave}
|
||||
onclick={(e) => handleClick(e, data, maxValue)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const last = data[data.length - 1];
|
||||
if (last?.date) openDateDetails(last.date);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- Y 轴 -->
|
||||
<line
|
||||
@@ -836,7 +894,7 @@
|
||||
|
||||
<!-- 当日详情 Drawer -->
|
||||
<Drawer.Root bind:open={dialogOpen}>
|
||||
<Drawer.Content class="sm:max-w-4xl">
|
||||
<Drawer.Content class="md:max-w-4xl">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Calendar class="h-5 w-5" />
|
||||
@@ -887,6 +945,8 @@
|
||||
showDescription={false}
|
||||
pageSize={8}
|
||||
{categories}
|
||||
onUpdate={handleRecordUpdated}
|
||||
onDelete={handleRecordDeleted}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
import Wallet from '@lucide/svelte/icons/wallet';
|
||||
import type { TotalStats } from '$lib/types/analysis';
|
||||
import { countByType } from '$lib/services/analysis';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
|
||||
interface Props {
|
||||
totalStats: TotalStats;
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
}
|
||||
|
||||
let { totalStats, records }: Props = $props();
|
||||
|
||||
@@ -1,97 +1,57 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import Flame from '@lucide/svelte/icons/flame';
|
||||
import Receipt from '@lucide/svelte/icons/receipt';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Store from '@lucide/svelte/icons/store';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import FileText from '@lucide/svelte/icons/file-text';
|
||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import { type UIBill } from '$lib/models/bill';
|
||||
import BillDetailDrawer from './BillDetailDrawer.svelte';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
categories: string[]; // 可用的分类列表
|
||||
onUpdate?: (record: BillRecord) => void;
|
||||
onUpdate?: (record: UIBill) => void;
|
||||
onDelete?: (record: UIBill) => void;
|
||||
}
|
||||
|
||||
let { records, categories, onUpdate }: Props = $props();
|
||||
let { records, categories, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let selectedRecord = $state<BillRecord | null>(null);
|
||||
let selectedRecord = $state<UIBill | null>(null);
|
||||
let selectedRank = $state(0);
|
||||
let isEditing = $state(false);
|
||||
|
||||
// 编辑表单数据
|
||||
let editForm = $state({
|
||||
merchant: '',
|
||||
category: '',
|
||||
amount: '',
|
||||
description: '',
|
||||
payment_method: ''
|
||||
});
|
||||
|
||||
function openDetail(record: BillRecord, rank: number) {
|
||||
function openDetail(record: UIBill, rank: number) {
|
||||
selectedRecord = record;
|
||||
selectedRank = rank;
|
||||
isEditing = false;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!selectedRecord) return;
|
||||
editForm = {
|
||||
merchant: selectedRecord.merchant,
|
||||
category: selectedRecord.category,
|
||||
amount: selectedRecord.amount,
|
||||
description: selectedRecord.description || '',
|
||||
payment_method: selectedRecord.payment_method || ''
|
||||
};
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!selectedRecord) return;
|
||||
|
||||
// 更新记录
|
||||
const updatedRecord: BillRecord = {
|
||||
...selectedRecord,
|
||||
merchant: editForm.merchant,
|
||||
category: editForm.category,
|
||||
amount: editForm.amount,
|
||||
description: editForm.description,
|
||||
payment_method: editForm.payment_method
|
||||
};
|
||||
|
||||
// 更新本地数据
|
||||
const index = records.findIndex(r => r === selectedRecord);
|
||||
if (index !== -1) {
|
||||
records[index] = updatedRecord;
|
||||
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||
const idx = records.findIndex(r => r === original);
|
||||
if (idx !== -1) {
|
||||
records[idx] = updated;
|
||||
// 触发响应式更新
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
selectedRecord = updatedRecord;
|
||||
isEditing = false;
|
||||
|
||||
// 通知父组件
|
||||
onUpdate?.(updatedRecord);
|
||||
selectedRecord = updated;
|
||||
onUpdate?.(updated);
|
||||
}
|
||||
|
||||
function handleCategoryChange(value: string | undefined) {
|
||||
if (value) {
|
||||
editForm.category = value;
|
||||
function handleRecordDeleted(deleted: UIBill) {
|
||||
const idx = records.findIndex(r => r === deleted);
|
||||
const finalIdx = idx !== -1
|
||||
? idx
|
||||
: records.findIndex(r =>
|
||||
r.time === deleted.time &&
|
||||
r.merchant === deleted.merchant &&
|
||||
r.amount === deleted.amount
|
||||
);
|
||||
|
||||
if (finalIdx !== -1) {
|
||||
records.splice(finalIdx, 1);
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
selectedRecord = null;
|
||||
selectedRank = 0;
|
||||
onDelete?.(deleted);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -125,7 +85,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="font-mono font-bold text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
¥{record.amount.toFixed(2)}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -133,157 +93,26 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- 账单详情弹窗 -->
|
||||
<Drawer.Root bind:open={dialogOpen}>
|
||||
<Drawer.Content class="sm:max-w-[450px]">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : '账单详情'}
|
||||
{#if selectedRank <= 3 && !isEditing}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {
|
||||
selectedRank === 1 ? 'bg-gradient-to-r from-yellow-400 to-amber-500 text-white' :
|
||||
selectedRank === 2 ? 'bg-gradient-to-r from-slate-300 to-slate-400 text-white' :
|
||||
'bg-gradient-to-r from-orange-400 to-amber-600 text-white'
|
||||
}">
|
||||
Top {selectedRank}
|
||||
</span>
|
||||
{/if}
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
{#if selectedRecord}
|
||||
{#if isEditing}
|
||||
<!-- 编辑模式 -->
|
||||
<div class="py-4 space-y-4 px-4 md:px-0">
|
||||
<div class="space-y-2">
|
||||
<Label for="amount">金额</Label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={editForm.amount}
|
||||
class="pl-7 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="merchant">商家</Label>
|
||||
<Input id="merchant" bind:value={editForm.merchant} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>分类</Label>
|
||||
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span>{editForm.category || '选择分类'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content>
|
||||
{#each categories as category}
|
||||
<Select.Item value={category}>{category}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="description">描述</Label>
|
||||
<Input id="description" bind:value={editForm.description} placeholder="可选" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="payment_method">支付方式</Label>
|
||||
<Input id="payment_method" bind:value={editForm.payment_method} placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 查看模式 -->
|
||||
<div class="py-4 space-y-4 px-4 md:px-0">
|
||||
<!-- 金额 -->
|
||||
<div class="text-center py-4 bg-red-50 dark:bg-red-950/30 rounded-lg">
|
||||
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
|
||||
<p class="text-3xl font-bold font-mono text-red-600 dark:text-red-400">
|
||||
¥{selectedRecord.amount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 详情列表 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Store class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">商家</p>
|
||||
<p class="font-medium">{selectedRecord.merchant}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Tag class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">分类</p>
|
||||
<p class="font-medium">{selectedRecord.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Calendar class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">时间</p>
|
||||
<p class="font-medium">{selectedRecord.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedRecord.description}
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<FileText class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">描述</p>
|
||||
<p class="font-medium">{selectedRecord.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedRecord.payment_method}
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<CreditCard class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted-foreground">支付方式</p>
|
||||
<p class="font-medium">{selectedRecord.payment_method}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<BillDetailDrawer
|
||||
bind:open={dialogOpen}
|
||||
bind:record={selectedRecord}
|
||||
{categories}
|
||||
title="账单详情"
|
||||
viewDescription="查看这笔支出的完整信息"
|
||||
editDescription="修改这笔支出的信息"
|
||||
onUpdate={handleRecordUpdated}
|
||||
onDelete={handleRecordDeleted}
|
||||
allowDelete={true}
|
||||
>
|
||||
{#snippet titleExtra({ isEditing })}
|
||||
{#if selectedRank <= 3 && !isEditing}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {
|
||||
selectedRank === 1 ? 'bg-gradient-to-r from-yellow-400 to-amber-500 text-white' :
|
||||
selectedRank === 2 ? 'bg-gradient-to-r from-slate-300 to-slate-400 text-white' :
|
||||
'bg-gradient-to-r from-orange-400 to-amber-600 text-white'
|
||||
}">
|
||||
Top {selectedRank}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<Drawer.Footer class="flex gap-2">
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit}>
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={() => dialogOpen = false}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onclick={startEdit}>
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
{/snippet}
|
||||
</BillDetailDrawer>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
config: ChartConfig;
|
||||
} = $props();
|
||||
|
||||
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
|
||||
let chartId = $derived.by(() => `chart-${id || uid.replace(/:/g, "")}`);
|
||||
|
||||
setChartContext({
|
||||
get config() {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
let { startDate = $bindable(), endDate = $bindable(), onchange, class: className }: Props = $props();
|
||||
|
||||
// 将 YYYY-MM-DD 字符串转换为 CalendarDate
|
||||
function parseDate(dateStr: string): DateValue | undefined {
|
||||
function parseDate(dateStr?: string): DateValue | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
return new CalendarDate(year, month, day);
|
||||
@@ -101,7 +101,11 @@
|
||||
<Popover.Content class="w-auto p-0" align="start">
|
||||
<RangeCalendar
|
||||
bind:value
|
||||
class="rounded-md border"
|
||||
numberOfMonths={2}
|
||||
pagedNavigation={true}
|
||||
fixedWeeks={true}
|
||||
weekdayFormat="short"
|
||||
locale="zh-CN"
|
||||
weekStartsOn={1}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-4 left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-0 gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg max-h-[calc(100dvh-2rem)] overflow-y-auto md:top-[50%] md:translate-y-[-50%] md:max-h-[85vh]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
{#if isMobile.current}
|
||||
<Sheet.Content
|
||||
{side}
|
||||
class={cn('max-h-[90vh] overflow-hidden flex flex-col', className)}
|
||||
class={cn('max-h-[90vh] overflow-y-auto flex flex-col', className)}
|
||||
>
|
||||
<!-- 拖拽指示器 (移动端抽屉常见设计) -->
|
||||
<div class="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted"></div>
|
||||
{@render children?.()}
|
||||
</Sheet.Content>
|
||||
{:else}
|
||||
<Dialog.Content class={cn('max-h-[85vh] overflow-hidden flex flex-col', className)}>
|
||||
<Dialog.Content class={cn('max-h-[85vh] overflow-y-auto flex flex-col', className)}>
|
||||
{@render children?.()}
|
||||
</Dialog.Content>
|
||||
{/if}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Monitor from '@lucide/svelte/icons/monitor';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import type { ComponentType } from 'svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export type ThemeMode = 'system' | 'light' | 'dark';
|
||||
|
||||
export interface ThemeOption {
|
||||
label: string;
|
||||
icon: ComponentType;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
export const themeConfig: Record<ThemeMode, ThemeOption> = {
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
|
||||
type DemoBillRow = {
|
||||
time: string;
|
||||
category: string;
|
||||
merchant: string;
|
||||
description: string;
|
||||
income_expense: string;
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
remark: string;
|
||||
needs_review: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 真实账单数据(来自支付宝和微信导出)
|
||||
* 数据已脱敏处理
|
||||
*/
|
||||
export const demoRecords: BillRecord[] = [
|
||||
const demoRows: DemoBillRow[] = [
|
||||
// ========== 支付宝数据 ==========
|
||||
{ time: "2026-01-07 12:01:02", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||
{ time: "2026-01-06 15:54:53", category: "餐饮美食", merchant: "友宝", description: "智能货柜消费", income_expense: "支出", amount: "7.19", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||
@@ -167,3 +180,16 @@ export const demoRecords: BillRecord[] = [
|
||||
{ time: "2025-12-08 19:15:45", category: "餐饮美食", merchant: "瑞幸咖啡", description: "咖啡", income_expense: "支出", amount: "12.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||
{ time: "2025-12-07 18:42:19", category: "餐饮美食", merchant: "奶茶店", description: "饮品", income_expense: "支出", amount: "15.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||
].sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
||||
|
||||
export const demoRecords: UIBill[] = demoRows.map((r) => ({
|
||||
time: r.time,
|
||||
category: r.category,
|
||||
merchant: r.merchant,
|
||||
description: r.description || '',
|
||||
incomeExpense: r.income_expense,
|
||||
amount: Number(r.amount || 0),
|
||||
paymentMethod: r.payment_method || '',
|
||||
status: r.status || '',
|
||||
remark: r.remark || '',
|
||||
reviewLevel: r.needs_review || '',
|
||||
}));
|
||||
|
||||
45
web/src/lib/models/bill.ts
Normal file
45
web/src/lib/models/bill.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { CleanedBill, UpdateBillRequest } from '$lib/api';
|
||||
|
||||
export interface UIBill {
|
||||
id?: string;
|
||||
time: string;
|
||||
category: string;
|
||||
merchant: string;
|
||||
description?: string;
|
||||
incomeExpense: string;
|
||||
amount: number;
|
||||
paymentMethod?: string;
|
||||
status?: string;
|
||||
remark?: string;
|
||||
reviewLevel?: string;
|
||||
}
|
||||
|
||||
export function cleanedBillToUIBill(bill: CleanedBill): UIBill {
|
||||
return {
|
||||
id: bill.id,
|
||||
time: bill.time,
|
||||
category: bill.category,
|
||||
merchant: bill.merchant,
|
||||
description: bill.description || '',
|
||||
incomeExpense: bill.income_expense,
|
||||
amount: bill.amount,
|
||||
paymentMethod: bill.pay_method || '',
|
||||
status: bill.status || '',
|
||||
remark: bill.remark || '',
|
||||
reviewLevel: bill.review_level || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function uiBillToUpdateBillRequest(bill: UIBill): UpdateBillRequest {
|
||||
return {
|
||||
time: bill.time,
|
||||
category: bill.category,
|
||||
merchant: bill.merchant,
|
||||
description: bill.description,
|
||||
income_expense: bill.incomeExpense,
|
||||
amount: bill.amount,
|
||||
pay_method: bill.paymentMethod,
|
||||
status: bill.status,
|
||||
remark: bill.remark,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
import type { CategoryStat, MonthlyStat, DailyExpenseData, TotalStats, PieChartDataItem } from '$lib/types/analysis';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
|
||||
/**
|
||||
* 计算分类统计
|
||||
*/
|
||||
export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
||||
export function calculateCategoryStats(records: UIBill[]): CategoryStat[] {
|
||||
const stats = new Map<string, { expense: number; income: number; count: number }>();
|
||||
|
||||
for (const r of records) {
|
||||
@@ -14,8 +14,8 @@ export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
||||
}
|
||||
const s = stats.get(r.category)!;
|
||||
s.count++;
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
if (r.income_expense === '支出') {
|
||||
const amount = r.amount || 0;
|
||||
if (r.incomeExpense === '支出') {
|
||||
s.expense += amount;
|
||||
} else {
|
||||
s.income += amount;
|
||||
@@ -30,7 +30,7 @@ export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
||||
/**
|
||||
* 计算月度统计
|
||||
*/
|
||||
export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
||||
export function calculateMonthlyStats(records: UIBill[]): MonthlyStat[] {
|
||||
const stats = new Map<string, { expense: number; income: number }>();
|
||||
|
||||
for (const r of records) {
|
||||
@@ -39,8 +39,8 @@ export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
||||
stats.set(month, { expense: 0, income: 0 });
|
||||
}
|
||||
const s = stats.get(month)!;
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
if (r.income_expense === '支出') {
|
||||
const amount = r.amount || 0;
|
||||
if (r.incomeExpense === '支出') {
|
||||
s.expense += amount;
|
||||
} else {
|
||||
s.income += amount;
|
||||
@@ -55,13 +55,13 @@ export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
||||
/**
|
||||
* 计算每日支出数据(用于面积图)
|
||||
*/
|
||||
export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseData[] {
|
||||
export function calculateDailyExpenseData(records: UIBill[]): DailyExpenseData[] {
|
||||
const stats = new Map<string, number>();
|
||||
|
||||
for (const r of records) {
|
||||
if (r.income_expense !== '支出') continue;
|
||||
if (r.incomeExpense !== '支出') continue;
|
||||
const date = r.time.substring(0, 10); // YYYY-MM-DD
|
||||
const amount = parseFloat(r.amount || '0');
|
||||
const amount = r.amount || 0;
|
||||
stats.set(date, (stats.get(date) || 0) + amount);
|
||||
}
|
||||
|
||||
@@ -73,14 +73,14 @@ export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseDa
|
||||
/**
|
||||
* 计算总计统计
|
||||
*/
|
||||
export function calculateTotalStats(records: BillRecord[]): TotalStats {
|
||||
export function calculateTotalStats(records: UIBill[]): TotalStats {
|
||||
return {
|
||||
expense: records
|
||||
.filter(r => r.income_expense === '支出')
|
||||
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
|
||||
.filter(r => r.incomeExpense === '支出')
|
||||
.reduce((sum, r) => sum + (r.amount || 0), 0),
|
||||
income: records
|
||||
.filter(r => r.income_expense === '收入')
|
||||
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
|
||||
.filter(r => r.incomeExpense === '收入')
|
||||
.reduce((sum, r) => sum + (r.amount || 0), 0),
|
||||
count: records.length,
|
||||
};
|
||||
}
|
||||
@@ -112,18 +112,18 @@ export function calculatePieChartData(
|
||||
/**
|
||||
* 获取 Top N 支出记录
|
||||
*/
|
||||
export function getTopExpenses(records: BillRecord[], n: number = 10): BillRecord[] {
|
||||
export function getTopExpenses(records: UIBill[], n: number = 10): UIBill[] {
|
||||
return records
|
||||
.filter(r => r.income_expense === '支出')
|
||||
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
|
||||
.filter(r => r.incomeExpense === '支出')
|
||||
.sort((a, b) => (b.amount || 0) - (a.amount || 0))
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计支出/收入笔数
|
||||
*/
|
||||
export function countByType(records: BillRecord[], type: '支出' | '收入'): number {
|
||||
return records.filter(r => r.income_expense === type).length;
|
||||
export function countByType(records: UIBill[], type: '支出' | '收入'): number {
|
||||
return records.filter(r => r.incomeExpense === type).length;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
|
||||
/** 分类统计数据 */
|
||||
export interface CategoryStat {
|
||||
@@ -47,7 +47,7 @@ export interface AnalysisState {
|
||||
fileName: string;
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
records: BillRecord[];
|
||||
records: UIBill[];
|
||||
isDemo: boolean;
|
||||
categoryChartMode: 'bar' | 'pie';
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import ChangelogDrawer from '$lib/components/ChangelogDrawer.svelte';
|
||||
|
||||
// Icons
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
@@ -24,6 +25,10 @@
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Bell from '@lucide/svelte/icons/bell';
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
// 版本号(从 Vite 编译时注入)
|
||||
const appVersion = __APP_VERSION__;
|
||||
|
||||
// Theme
|
||||
import {
|
||||
@@ -42,6 +47,7 @@
|
||||
let checkingHealth = $state(true);
|
||||
let isAuthenticated = $state(false);
|
||||
let currentUser = $state<AuthUser | null>(null);
|
||||
let changelogOpen = $state(false);
|
||||
|
||||
// 订阅认证状态
|
||||
$effect(() => {
|
||||
@@ -67,16 +73,28 @@
|
||||
onMount(() => {
|
||||
themeMode = loadThemeFromStorage();
|
||||
applyThemeToDocument(themeMode);
|
||||
|
||||
// 检查登录状态,未登录则跳转到登录页
|
||||
const pathname = $page.url.pathname;
|
||||
if (!auth.check() && pathname !== '/login' && pathname !== '/health') {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查服务器状态
|
||||
checkServerHealth();
|
||||
|
||||
(async () => {
|
||||
// 检查登录状态,未登录则跳转到登录页
|
||||
const pathname = $page.url.pathname;
|
||||
if (!auth.check() && pathname !== '/login' && pathname !== '/health') {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// 由后端判断 Token 是否过期/无效
|
||||
if (auth.check() && pathname !== '/login') {
|
||||
const ok = await auth.validateToken();
|
||||
if (!ok) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查服务器状态
|
||||
checkServerHealth();
|
||||
})();
|
||||
|
||||
// 每 30 秒检查一次
|
||||
const healthInterval = setInterval(checkServerHealth, 30000);
|
||||
|
||||
@@ -211,6 +229,18 @@
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
|
||||
<!-- 版本号 -->
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
{#snippet child({ props })}
|
||||
<button {...props} onclick={() => changelogOpen = true} title="查看更新日志">
|
||||
<Info class="size-4" />
|
||||
<span>v{appVersion}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
@@ -219,6 +249,7 @@
|
||||
<!-- Footer: 用户信息 -->
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.Menu>
|
||||
<!-- 用户信息 -->
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
@@ -332,4 +363,7 @@
|
||||
</main>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<!-- Changelog 抽屉 -->
|
||||
<ChangelogDrawer bind:open={changelogOpen} />
|
||||
{/if}
|
||||
|
||||
@@ -23,35 +23,46 @@
|
||||
let isUploading = $state(false);
|
||||
let uploadResult: UploadResponse | null = $state(null);
|
||||
let errorMessage = $state('');
|
||||
let zipPassword = $state('');
|
||||
let isZipFile = $state(false);
|
||||
|
||||
type StatTrend = 'up' | 'down';
|
||||
interface StatCard {
|
||||
title: string;
|
||||
value: string;
|
||||
change: string;
|
||||
trend: StatTrend;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// 实时统计数据
|
||||
let stats = $state([
|
||||
let stats = $state<StatCard[]>([
|
||||
{
|
||||
title: '本月支出',
|
||||
value: '¥0.00',
|
||||
change: '+0%',
|
||||
trend: 'up' as const,
|
||||
trend: 'up',
|
||||
description: '加载中...'
|
||||
},
|
||||
{
|
||||
title: '本月收入',
|
||||
value: '¥0.00',
|
||||
change: '+0%',
|
||||
trend: 'up' as const,
|
||||
trend: 'up',
|
||||
description: '加载中...'
|
||||
},
|
||||
{
|
||||
title: '待复核',
|
||||
value: '0',
|
||||
change: '+0%',
|
||||
trend: 'up' as const,
|
||||
trend: 'up',
|
||||
description: '需要人工确认'
|
||||
},
|
||||
{
|
||||
title: '已处理账单',
|
||||
value: '0',
|
||||
change: '+0%',
|
||||
trend: 'up' as const,
|
||||
trend: 'up',
|
||||
description: '累计处理记录'
|
||||
},
|
||||
]);
|
||||
@@ -66,62 +77,49 @@
|
||||
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const previousMonth = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
console.log('Current month:', currentMonth);
|
||||
console.log('Previous month:', previousMonth);
|
||||
|
||||
// 获取月度统计数据
|
||||
const monthlyResponse = await fetchMonthlyStats();
|
||||
console.log('Monthly response:', monthlyResponse);
|
||||
const monthlyStats = monthlyResponse.data || [];
|
||||
console.log('Monthly stats:', monthlyStats);
|
||||
|
||||
// 获取待复核统计
|
||||
const reviewResponse = await fetchReviewStats();
|
||||
console.log('Review response:', reviewResponse);
|
||||
const reviewTotal = reviewResponse.data?.total || 0;
|
||||
console.log('Review total:', reviewTotal);
|
||||
|
||||
// 获取已处理账单数量
|
||||
const billsResponse = await fetchBills({ page_size: 1 });
|
||||
console.log('Bills response:', billsResponse);
|
||||
const billTotal = billsResponse.data?.total || 0;
|
||||
console.log('Bill total:', billTotal);
|
||||
|
||||
// 提取当月和上月的数据
|
||||
const currentData = monthlyStats.find(m => m.month === currentMonth);
|
||||
const previousData = monthlyStats.find(m => m.month === previousMonth);
|
||||
|
||||
console.log('Current data:', currentData);
|
||||
console.log('Previous data:', previousData);
|
||||
|
||||
// 计算支出变化百分比
|
||||
const currentExpense = currentData?.expense || 0;
|
||||
const previousExpense = previousData?.expense || 0;
|
||||
const expenseChange = previousExpense > 0
|
||||
? ((currentExpense - previousExpense) / previousExpense * 100).toFixed(1)
|
||||
? (currentExpense - previousExpense) / previousExpense * 100
|
||||
: 0;
|
||||
const expenseTrend = parseFloat(expenseChange.toString()) >= 0 ? 'up' : 'down';
|
||||
const expenseTrend: StatTrend = expenseChange >= 0 ? 'up' : 'down';
|
||||
|
||||
// 计算收入变化百分比
|
||||
const currentIncome = currentData?.income || 0;
|
||||
const previousIncome = previousData?.income || 0;
|
||||
const incomeChange = previousIncome > 0
|
||||
? ((currentIncome - previousIncome) / previousIncome * 100).toFixed(1)
|
||||
? (currentIncome - previousIncome) / previousIncome * 100
|
||||
: 0;
|
||||
const incomeTrend = parseFloat(incomeChange.toString()) >= 0 ? 'up' : 'down';
|
||||
const incomeTrend: StatTrend = incomeChange >= 0 ? 'up' : 'down';
|
||||
|
||||
// 格式化金额
|
||||
const formatAmount = (amount: number) => {
|
||||
return `¥${amount.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatChange = (change: number | string) => {
|
||||
const changeNum = typeof change === 'string' ? parseFloat(change) : change;
|
||||
const sign = changeNum >= 0 ? '+' : '';
|
||||
return `${sign}${changeNum.toFixed(1)}%`;
|
||||
const formatChange = (change: number) => {
|
||||
const sign = change >= 0 ? '+' : '';
|
||||
return `${sign}${change.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const newStats = [
|
||||
const newStats: StatCard[] = [
|
||||
{
|
||||
title: '本月支出',
|
||||
value: formatAmount(currentExpense),
|
||||
@@ -140,19 +138,18 @@
|
||||
title: '待复核',
|
||||
value: reviewTotal.toString(),
|
||||
change: '+0%',
|
||||
trend: 'up' as const,
|
||||
trend: 'up',
|
||||
description: '需要人工确认'
|
||||
},
|
||||
{
|
||||
title: '已处理账单',
|
||||
value: billTotal.toString(),
|
||||
change: '+0%',
|
||||
trend: 'up' as const,
|
||||
trend: 'up',
|
||||
description: '累计处理记录'
|
||||
},
|
||||
];
|
||||
|
||||
console.log('New stats:', newStats);
|
||||
|
||||
stats = newStats;
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
@@ -191,20 +188,33 @@
|
||||
}
|
||||
|
||||
function selectFile(file: File) {
|
||||
if (!file.name.endsWith('.csv')) {
|
||||
errorMessage = '请选择 CSV 格式的账单文件';
|
||||
const fileName = file.name.toLowerCase();
|
||||
const isZip = fileName.endsWith('.zip');
|
||||
const isCsv = fileName.endsWith('.csv');
|
||||
const isXlsx = fileName.endsWith('.xlsx');
|
||||
|
||||
if (!isCsv && !isZip && !isXlsx) {
|
||||
errorMessage = '请选择 CSV、XLSX 或 ZIP 格式的账单文件';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
isZipFile = isZip;
|
||||
errorMessage = '';
|
||||
uploadResult = null;
|
||||
|
||||
// 如果不是 ZIP 文件,清空密码
|
||||
if (!isZip) {
|
||||
zipPassword = '';
|
||||
}
|
||||
|
||||
// 根据文件名自动识别账单类型
|
||||
const fileName = file.name.toLowerCase();
|
||||
if (fileName.includes('支付宝') || fileName.includes('alipay')) {
|
||||
selectedType = 'alipay';
|
||||
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
|
||||
selectedType = 'wechat';
|
||||
} else if (fileName.includes('京东') || fileName.includes('jd')) {
|
||||
selectedType = 'jd';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +222,8 @@
|
||||
selectedFile = null;
|
||||
uploadResult = null;
|
||||
errorMessage = '';
|
||||
zipPassword = '';
|
||||
isZipFile = false;
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
@@ -221,7 +233,11 @@
|
||||
errorMessage = '';
|
||||
|
||||
try {
|
||||
const result = await uploadBill(selectedFile, selectedType);
|
||||
const options: { year?: number; month?: number; password?: string } = {};
|
||||
if (isZipFile && zipPassword) {
|
||||
options.password = zipPassword;
|
||||
}
|
||||
const result = await uploadBill(selectedFile, selectedType, options);
|
||||
if (result.result) {
|
||||
uploadResult = result;
|
||||
} else {
|
||||
@@ -249,7 +265,7 @@
|
||||
<!-- 页面标题 -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">账单管理</h1>
|
||||
<p class="text-muted-foreground">上传并分析您的支付宝、微信账单</p>
|
||||
<p class="text-muted-foreground">上传并分析您的支付宝、微信、京东账单</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
@@ -283,7 +299,7 @@
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<Card.Title>上传账单</Card.Title>
|
||||
<Card.Description>支持支付宝、微信账单 CSV 文件</Card.Description>
|
||||
<Card.Description>支持支付宝、微信、京东账单 CSV、XLSX 或 ZIP 文件</Card.Description>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={() => goto('/bills?tab=manual')}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
@@ -306,7 +322,7 @@
|
||||
<input
|
||||
type="file"
|
||||
id="file-input"
|
||||
accept=".csv"
|
||||
accept=".csv,.xlsx,.zip"
|
||||
onchange={handleFileSelect}
|
||||
hidden
|
||||
/>
|
||||
@@ -338,7 +354,7 @@
|
||||
<p class="font-medium">
|
||||
{isDragOver ? '松开鼠标上传文件' : '拖拽文件到这里,或点击选择'}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">支持 .csv 格式</p>
|
||||
<p class="text-sm text-muted-foreground">支持 .csv、.xlsx、.zip 格式</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -352,6 +368,19 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ZIP 密码输入 -->
|
||||
{#if isZipFile}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium">ZIP 密码:</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={zipPassword}
|
||||
placeholder="如有密码请输入"
|
||||
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 账单类型选择 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium">账单类型:</span>
|
||||
@@ -370,6 +399,13 @@
|
||||
>
|
||||
微信
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedType === 'jd' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => selectedType = 'jd'}
|
||||
>
|
||||
京东
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -411,7 +447,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">账单类型</span>
|
||||
<Badge variant="secondary">
|
||||
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : '微信'}
|
||||
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : uploadResult.data?.bill_type === 'wechat' ? '微信' : '京东'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchBills, fetchMonthlyStats, checkHealth, type CleanedBill, type MonthlyStat } from '$lib/api';
|
||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -10,7 +11,6 @@
|
||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
|
||||
// 分析组件
|
||||
import {
|
||||
@@ -58,6 +58,10 @@
|
||||
let isDemo = $state(false);
|
||||
let serverAvailable = $state(true);
|
||||
|
||||
// 后端返回的聚合统计(准确的总支出/收入)
|
||||
let backendTotalExpense = $state(0);
|
||||
let backendTotalIncome = $state(0);
|
||||
|
||||
// 日期范围筛选 - 初始化为默认值
|
||||
let startYear = $state(defaultDates.startYear);
|
||||
let startMonth = $state(defaultDates.startMonth);
|
||||
@@ -71,31 +75,84 @@
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 将 CleanedBill 转换为分析服务需要的格式
|
||||
function toAnalysisRecords(bills: CleanedBill[]) {
|
||||
return bills.map(bill => ({
|
||||
time: bill.time,
|
||||
category: bill.category,
|
||||
merchant: bill.merchant,
|
||||
description: bill.description,
|
||||
income_expense: bill.income_expense,
|
||||
amount: String(bill.amount),
|
||||
payment_method: bill.pay_method,
|
||||
status: bill.status,
|
||||
remark: bill.remark,
|
||||
needs_review: bill.review_level,
|
||||
}));
|
||||
}
|
||||
|
||||
// 派生分析数据
|
||||
let analysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(records));
|
||||
let allAnalysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(allRecords)); // 全部数据用于每日趋势图
|
||||
// 派生分析数据(统一成 UIBill)
|
||||
let analysisRecords: UIBill[] = $derived.by(() => (isDemo ? demoRecords : records.map(cleanedBillToUIBill)));
|
||||
let allAnalysisRecords: UIBill[] = $derived.by(() => (isDemo ? demoRecords : allRecords.map(cleanedBillToUIBill)));
|
||||
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
|
||||
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
|
||||
let totalStats = $derived(calculateTotalStats(analysisRecords));
|
||||
|
||||
// 使用后端返回的聚合统计(准确),如果没有则使用前端计算(作为后备)
|
||||
let totalStats = $derived({
|
||||
expense: backendTotalExpense > 0 ? backendTotalExpense : calculateTotalStats(analysisRecords).expense,
|
||||
income: backendTotalIncome > 0 ? backendTotalIncome : calculateTotalStats(analysisRecords).income,
|
||||
count: analysisRecords.length,
|
||||
});
|
||||
|
||||
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
|
||||
let topExpenses = $derived(getTopExpenses(analysisRecords, 10));
|
||||
|
||||
// 账单更新处理
|
||||
function handleBillUpdated(updated: UIBill) {
|
||||
// 在 records 中查找并更新对应的账单
|
||||
const idx = records.findIndex(r =>
|
||||
r.id === (updated as unknown as { id?: string }).id ||
|
||||
(r.time === updated.time && r.merchant === updated.merchant && r.amount === updated.amount)
|
||||
);
|
||||
|
||||
if (idx !== -1) {
|
||||
// 更新后端格式的记录
|
||||
records[idx] = {
|
||||
...records[idx],
|
||||
time: updated.time,
|
||||
category: updated.category,
|
||||
merchant: updated.merchant,
|
||||
description: updated.description || '',
|
||||
amount: updated.amount,
|
||||
pay_method: updated.paymentMethod || '',
|
||||
status: updated.status || records[idx].status,
|
||||
remark: updated.remark || records[idx].remark,
|
||||
};
|
||||
// 触发响应式更新
|
||||
records = [...records];
|
||||
|
||||
// 同时更新 allRecords(如果账单在全部数据中)
|
||||
const allIdx = allRecords.findIndex(r =>
|
||||
r.id === (updated as unknown as { id?: string }).id ||
|
||||
(r.time === updated.time && r.merchant === updated.merchant)
|
||||
);
|
||||
if (allIdx !== -1) {
|
||||
allRecords[allIdx] = records[idx];
|
||||
allRecords = [...allRecords];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBillDeleted(deleted: UIBill) {
|
||||
const idx = records.findIndex(r =>
|
||||
r.id === (deleted as unknown as { id?: string }).id ||
|
||||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||
);
|
||||
if (idx !== -1) {
|
||||
records.splice(idx, 1);
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
const allIdx = allRecords.findIndex(r =>
|
||||
r.id === (deleted as unknown as { id?: string }).id ||
|
||||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||
);
|
||||
if (allIdx !== -1) {
|
||||
allRecords.splice(allIdx, 1);
|
||||
allRecords = [...allRecords];
|
||||
}
|
||||
|
||||
if (deleted.incomeExpense === '支出') {
|
||||
backendTotalExpense = Math.max(0, backendTotalExpense - deleted.amount);
|
||||
} else if (deleted.incomeExpense === '收入') {
|
||||
backendTotalIncome = Math.max(0, backendTotalIncome - deleted.amount);
|
||||
}
|
||||
}
|
||||
|
||||
// 分类列表按数据中出现次数排序
|
||||
let sortedCategories = $derived(() => {
|
||||
const categoryCounts = new Map<string, number>();
|
||||
@@ -138,6 +195,9 @@
|
||||
// 处理账单数据
|
||||
if (billsResponse.result && billsResponse.data) {
|
||||
records = billsResponse.data.bills || [];
|
||||
// 使用后端返回的聚合统计(准确的总支出/收入)
|
||||
backendTotalExpense = billsResponse.data.total_expense || 0;
|
||||
backendTotalIncome = billsResponse.data.total_income || 0;
|
||||
if (records.length === 0) {
|
||||
errorMessage = '暂无账单数据';
|
||||
}
|
||||
@@ -255,7 +315,12 @@
|
||||
<OverviewCards {totalStats} records={analysisRecords} />
|
||||
|
||||
<!-- 每日支出趋势图(按分类堆叠) - 使用全部数据 -->
|
||||
<DailyTrendChart records={allAnalysisRecords} categories={sortedCategories()} />
|
||||
<DailyTrendChart
|
||||
records={allAnalysisRecords}
|
||||
categories={sortedCategories()}
|
||||
onUpdate={handleBillUpdated}
|
||||
onDelete={handleBillDeleted}
|
||||
/>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- 分类支出排行 -->
|
||||
@@ -265,6 +330,7 @@
|
||||
totalExpense={totalStats.expense}
|
||||
records={analysisRecords}
|
||||
categories={sortedCategories()}
|
||||
onUpdate={handleBillUpdated}
|
||||
/>
|
||||
|
||||
<!-- 月度趋势 -->
|
||||
@@ -272,7 +338,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Top 10 支出 -->
|
||||
<TopExpenses records={topExpenses} categories={sortedCategories()} />
|
||||
<TopExpenses
|
||||
records={topExpenses}
|
||||
categories={sortedCategories()}
|
||||
onUpdate={handleBillUpdated}
|
||||
onDelete={handleBillDeleted}
|
||||
/>
|
||||
{:else}
|
||||
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
|
||||
<Card.Root>
|
||||
|
||||
@@ -4,11 +4,15 @@ import type { RequestHandler } from './$types';
|
||||
// 服务端使用 Docker 内部地址,默认使用 localhost
|
||||
const API_URL = env.API_URL || 'http://localhost:8080';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, fetch }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, request, fetch }) => {
|
||||
const path = params.path;
|
||||
const queryString = url.search;
|
||||
|
||||
const response = await fetch(`${API_URL}/api/${path}${queryString}`);
|
||||
const response = await fetch(`${API_URL}/api/${path}${queryString}`, {
|
||||
headers: {
|
||||
'Authorization': request.headers.get('Authorization') || '',
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
@@ -27,6 +31,7 @@ export const POST: RequestHandler = async ({ params, request, fetch }) => {
|
||||
body: await request.arrayBuffer(),
|
||||
headers: {
|
||||
'Content-Type': request.headers.get('Content-Type') || 'application/octet-stream',
|
||||
'Authorization': request.headers.get('Authorization') || '',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
|
||||
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
|
||||
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
|
||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||
import { formatLocalDate, formatDateTime } from '$lib/utils';
|
||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||
@@ -178,6 +180,51 @@
|
||||
// 重新加载账单列表(保持当前日期筛选)
|
||||
loadBills();
|
||||
}
|
||||
|
||||
// 账单详情抽屉
|
||||
let drawerOpen = $state(false);
|
||||
let selectedBill = $state<UIBill | null>(null);
|
||||
|
||||
// 点击行打开详情
|
||||
function handleRowClick(record: CleanedBill) {
|
||||
selectedBill = cleanedBillToUIBill(record);
|
||||
drawerOpen = true;
|
||||
}
|
||||
|
||||
// 更新账单后刷新列表
|
||||
function handleBillUpdate(updated: UIBill) {
|
||||
// 在当前列表中更新对应的记录
|
||||
const index = records.findIndex(r => r.id === updated.id);
|
||||
if (index !== -1) {
|
||||
records[index] = {
|
||||
...records[index],
|
||||
time: updated.time,
|
||||
category: updated.category,
|
||||
merchant: updated.merchant,
|
||||
description: updated.description || '',
|
||||
income_expense: updated.incomeExpense,
|
||||
amount: updated.amount,
|
||||
pay_method: updated.paymentMethod || '',
|
||||
status: updated.status || '',
|
||||
remark: updated.remark || '',
|
||||
review_level: updated.reviewLevel || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 删除账单后刷新列表
|
||||
function handleBillDelete(deleted: UIBill) {
|
||||
// 从列表中移除对应的记录
|
||||
records = records.filter(r => r.id !== deleted.id);
|
||||
totalRecords = Math.max(0, totalRecords - 1);
|
||||
|
||||
// 更新聚合统计
|
||||
if (deleted.incomeExpense === '支出') {
|
||||
totalExpense = Math.max(0, totalExpense - deleted.amount);
|
||||
} else if (deleted.incomeExpense === '收入') {
|
||||
totalIncome = Math.max(0, totalIncome - deleted.amount);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -333,13 +380,14 @@
|
||||
<Label class="text-xs">来源</Label>
|
||||
<Select.Root type="single" value={filterBillType || undefined} onValueChange={handleBillTypeChange}>
|
||||
<Select.Trigger class="h-9 w-full">
|
||||
<span class="text-sm">{filterBillType === 'alipay' ? '支付宝' : filterBillType === 'wechat' ? '微信' : filterBillType === 'manual' ? '手动' : '全部'}</span>
|
||||
<span class="text-sm">{filterBillType === 'alipay' ? '支付宝' : filterBillType === 'wechat' ? '微信' : filterBillType === 'jd' ? '京东' : filterBillType === 'manual' ? '手动' : '全部'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content>
|
||||
<Select.Item value="">全部</Select.Item>
|
||||
<Select.Item value="alipay">支付宝</Select.Item>
|
||||
<Select.Item value="wechat">微信</Select.Item>
|
||||
<Select.Item value="jd">京东</Select.Item>
|
||||
<Select.Item value="manual">手动</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
@@ -383,13 +431,16 @@
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each displayRecords as record}
|
||||
<Table.Row>
|
||||
<Table.Row
|
||||
class="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onclick={() => handleRowClick(record)}
|
||||
>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{formatDateTime(record.time)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="hidden xl:table-cell">
|
||||
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : 'secondary')}>
|
||||
{record.bill_type === 'manual' ? '手动输入' : (record.bill_type === 'alipay' ? '支付宝' : '微信')}
|
||||
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : (record.bill_type === 'jd' ? 'destructive' : 'secondary'))}>
|
||||
{record.bill_type === 'manual' ? '手动输入' : (record.bill_type === 'alipay' ? '支付宝' : (record.bill_type === 'jd' ? '京东' : '微信'))}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
@@ -486,3 +537,16 @@
|
||||
<ManualBillInput onSuccess={handleManualBillSuccess} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 账单详情抽屉 -->
|
||||
<BillDetailDrawer
|
||||
bind:open={drawerOpen}
|
||||
bind:record={selectedBill}
|
||||
categories={categories}
|
||||
title="账单详情"
|
||||
viewDescription="查看这笔账单的完整信息"
|
||||
editDescription="修改这笔账单的信息"
|
||||
allowDelete={true}
|
||||
onUpdate={handleBillUpdate}
|
||||
onDelete={handleBillDelete}
|
||||
/>
|
||||
|
||||
@@ -5,19 +5,26 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
|
||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||
import { updateBill } from '$lib/api';
|
||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import PartyPopper from '@lucide/svelte/icons/party-popper';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
|
||||
let isLoading = $state(true);
|
||||
let errorMessage = $state('');
|
||||
let reviewStats: ReviewData | null = $state(null);
|
||||
let allBills: CleanedBill[] = $state([]);
|
||||
let reviewStats = $state<ReviewData | null>(null);
|
||||
let allBills = $state<CleanedBill[]>([]);
|
||||
let filterLevel = $state<'all' | 'HIGH' | 'LOW'>('all');
|
||||
|
||||
// 快捷确认按钮的加载状态 (记录ID -> 是否在加载)
|
||||
let confirmingBills = $state<Map<string, boolean>>(new Map());
|
||||
|
||||
onMount(() => {
|
||||
loadReviewData();
|
||||
});
|
||||
@@ -68,6 +75,106 @@
|
||||
let totalCount = $derived(reviewStats?.total || 0);
|
||||
let highCount = $derived(reviewStats?.high || 0);
|
||||
let lowCount = $derived(reviewStats?.low || 0);
|
||||
|
||||
// 账单详情抽屉
|
||||
let drawerOpen = $state(false);
|
||||
let selectedBill = $state<UIBill | null>(null);
|
||||
|
||||
// 分类列表(用于编辑选择)
|
||||
const categories = [
|
||||
'餐饮美食', '交通出行', '生活服务', '日用百货',
|
||||
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
|
||||
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
|
||||
];
|
||||
|
||||
// 点击行打开详情
|
||||
function handleRowClick(record: CleanedBill) {
|
||||
selectedBill = cleanedBillToUIBill(record);
|
||||
drawerOpen = true;
|
||||
}
|
||||
|
||||
// 快捷确认(仅清除 review_level,不修改其他字段)
|
||||
async function quickConfirm(record: CleanedBill, event: Event) {
|
||||
// 阻止事件冒泡,避免触发行点击
|
||||
event.stopPropagation();
|
||||
|
||||
if (confirmingBills.get(record.id)) return;
|
||||
|
||||
// 设置加载状态
|
||||
confirmingBills.set(record.id, true);
|
||||
confirmingBills = new Map(confirmingBills);
|
||||
|
||||
try {
|
||||
const resp = await updateBill(record.id, { review_level: '' });
|
||||
|
||||
if (resp.result) {
|
||||
// 从列表中移除该记录
|
||||
const index = allBills.findIndex(r => r.id === record.id);
|
||||
if (index !== -1) {
|
||||
allBills.splice(index, 1);
|
||||
allBills = [...allBills];
|
||||
}
|
||||
|
||||
// 更新统计数据
|
||||
if (reviewStats) {
|
||||
reviewStats = {
|
||||
...reviewStats,
|
||||
total: Math.max(0, reviewStats.total - 1),
|
||||
high: record.review_level === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
|
||||
low: record.review_level === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('快捷确认失败:', err);
|
||||
// 这里可以添加错误提示
|
||||
} finally {
|
||||
// 清除加载状态
|
||||
confirmingBills.delete(record.id);
|
||||
confirmingBills = new Map(confirmingBills);
|
||||
}
|
||||
}
|
||||
|
||||
// 复核完成后从列表中移除该记录
|
||||
function handleBillUpdate(updated: UIBill, original: UIBill) {
|
||||
// 更新后 review_level 已被清除,从列表中移除
|
||||
const index = allBills.findIndex(r => r.id === updated.id);
|
||||
if (index !== -1) {
|
||||
allBills.splice(index, 1);
|
||||
// 触发响应式更新
|
||||
allBills = [...allBills];
|
||||
}
|
||||
// 更新统计数据(根据原始的 review_level 减少计数)
|
||||
if (reviewStats) {
|
||||
reviewStats = {
|
||||
...reviewStats,
|
||||
total: Math.max(0, reviewStats.total - 1),
|
||||
high: original.reviewLevel === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
|
||||
low: original.reviewLevel === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
|
||||
};
|
||||
}
|
||||
// 关闭抽屉
|
||||
drawerOpen = false;
|
||||
}
|
||||
|
||||
// 删除账单后从列表中移除该记录
|
||||
function handleBillDelete(deleted: UIBill) {
|
||||
// 从列表中移除对应的记录
|
||||
const index = allBills.findIndex(r => r.id === deleted.id);
|
||||
if (index !== -1) {
|
||||
allBills.splice(index, 1);
|
||||
allBills = [...allBills];
|
||||
}
|
||||
// 更新统计数据
|
||||
if (reviewStats) {
|
||||
reviewStats = {
|
||||
...reviewStats,
|
||||
total: Math.max(0, reviewStats.total - 1),
|
||||
high: deleted.reviewLevel === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
|
||||
low: deleted.reviewLevel === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -117,7 +224,7 @@
|
||||
{#if reviewStats && !isLoading}
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card.Root>
|
||||
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1 cursor-default">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">待复核总数</Card.Title>
|
||||
<Clock class="h-4 w-4 text-muted-foreground" />
|
||||
@@ -128,7 +235,7 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="border-red-200 dark:border-red-900">
|
||||
<Card.Root class="border-red-200 dark:border-red-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-red-300 dark:hover:border-red-800 cursor-default">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">高优先级</Card.Title>
|
||||
<AlertTriangle class="h-4 w-4 text-red-500" />
|
||||
@@ -139,7 +246,7 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="border-yellow-200 dark:border-yellow-900">
|
||||
<Card.Root class="border-yellow-200 dark:border-yellow-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-yellow-300 dark:hover:border-yellow-800 cursor-default">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">低优先级</Card.Title>
|
||||
<AlertCircle class="h-4 w-4 text-yellow-500" />
|
||||
@@ -194,11 +301,15 @@
|
||||
<Table.Head>收/支</Table.Head>
|
||||
<Table.Head class="text-right">金额</Table.Head>
|
||||
<Table.Head class="w-[80px]">优先级</Table.Head>
|
||||
<Table.Head class="w-[100px] text-center">操作</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filteredRecords as record}
|
||||
<Table.Row>
|
||||
<Table.Row
|
||||
class="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onclick={() => handleRowClick(record)}
|
||||
>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{record.time ? new Date(record.time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}
|
||||
</Table.Cell>
|
||||
@@ -224,6 +335,23 @@
|
||||
{record.review_level}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 px-2 text-xs"
|
||||
onclick={(e) => quickConfirm(record, e)}
|
||||
disabled={confirmingBills.get(record.id) || false}
|
||||
title="确认分类正确"
|
||||
>
|
||||
{#if confirmingBills.get(record.id)}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{:else}
|
||||
<Check class="h-3 w-3 mr-1" />
|
||||
确认
|
||||
{/if}
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
@@ -240,3 +368,17 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 账单详情抽屉(复核模式) -->
|
||||
<BillDetailDrawer
|
||||
bind:open={drawerOpen}
|
||||
bind:record={selectedBill}
|
||||
categories={categories}
|
||||
title="复核账单"
|
||||
viewDescription="确认或修改这笔账单的分类"
|
||||
editDescription="修改这笔账单的分类信息"
|
||||
clearReviewLevel={true}
|
||||
allowDelete={true}
|
||||
onUpdate={handleBillUpdate}
|
||||
onDelete={handleBillDelete}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,18 @@ import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit(), tailwindcss()],
|
||||
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version)
|
||||
},
|
||||
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Webhook 服务 Dockerfile
|
||||
# 多阶段构建:编译阶段 + 运行阶段
|
||||
|
||||
# ===== 编译阶段 =====
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# 配置 Alpine 镜像源(国内)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
# 配置 Go 代理(国内镜像)
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
# 安装编译依赖
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# 复制代码
|
||||
COPY . .
|
||||
|
||||
# 构建二进制
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o webhook .
|
||||
|
||||
# ===== 运行阶段 =====
|
||||
FROM alpine:3.19
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 配置 Alpine 镜像源(国内)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
# 安装必要工具
|
||||
RUN apk add --no-cache bash curl git docker-cli docker-compose ca-certificates tzdata
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 从编译阶段复制二进制
|
||||
COPY --from=builder /build/webhook /usr/local/bin/webhook
|
||||
|
||||
# 注意:deploy.sh 通过 volume 挂载,不在构建时复制
|
||||
# 因为 deploy.sh 在仓库根目录,而 Dockerfile 在 webhook 目录
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 9000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
|
||||
CMD curl -f http://localhost:9000/health || exit 1
|
||||
|
||||
# 启动服务
|
||||
CMD ["webhook"]
|
||||
@@ -1,132 +0,0 @@
|
||||
# Webhook 服务
|
||||
|
||||
用于监听 Gitea webhook 事件,自动触发服务器拉取最新代码并重新构建部署。
|
||||
|
||||
## 特性
|
||||
|
||||
- ✅ 接收 Gitea webhook 推送事件
|
||||
- ✅ HMAC-SHA256 签名验证
|
||||
- ✅ 仅处理主分支 (master/main) 的推送
|
||||
- ✅ 自动执行部署脚本
|
||||
- ✅ 异步部署,快速响应
|
||||
- ✅ 零停机热更新
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|-----|------|-------|
|
||||
| `WEBHOOK_PORT` | webhook 服务监听端口 | 9000 |
|
||||
| `WEBHOOK_SECRET` | Gitea webhook 签名密钥 | your-webhook-secret |
|
||||
| `REPO_PATH` | 仓库根目录路径 | /app |
|
||||
|
||||
### 部署脚本
|
||||
|
||||
脚本位置: `/app/deploy.sh`
|
||||
|
||||
执行以下操作:
|
||||
1. 拉取最新代码 (`git pull`)
|
||||
2. 热更新部署(零停机)
|
||||
3. 自动清理旧镜像
|
||||
4. 健康检查
|
||||
|
||||
## 安全性
|
||||
|
||||
### 设置 Webhook Secret
|
||||
|
||||
在 Gitea 中配置 webhook 时,需要设置 Secret:
|
||||
|
||||
1. 生成一个随机密钥:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
# 输出示例: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
```
|
||||
|
||||
2. 在 Docker 容器中设置环境变量:
|
||||
```yaml
|
||||
environment:
|
||||
WEBHOOK_SECRET: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
||||
```
|
||||
|
||||
## Gitea Webhook 配置
|
||||
|
||||
### 1. 进入仓库设置
|
||||
|
||||
在 Gitea 中打开仓库,进入 **设置 → Webhook**
|
||||
|
||||
### 2. 添加新 Webhook
|
||||
|
||||
- **URL**: `http://<your-server>:9000/webhook`
|
||||
- **内容类型**: `application/json`
|
||||
- **密钥**: 使用上面生成的 Secret
|
||||
- **触发器**: 勾选 `推送事件`
|
||||
- **活跃**: 是
|
||||
|
||||
### 3. 测试连接
|
||||
|
||||
点击 "测试" 按钮验证连接
|
||||
|
||||
## 日志
|
||||
|
||||
查看部署日志:
|
||||
|
||||
```bash
|
||||
# 查看容器日志
|
||||
docker logs billai-webhook
|
||||
|
||||
# 查看部署脚本日志
|
||||
docker exec billai-webhook tail -f /tmp/billai_deploy.log
|
||||
```
|
||||
|
||||
## 访问端点
|
||||
|
||||
- **Webhook**: `POST /webhook`
|
||||
- **健康检查**: `GET /health`
|
||||
|
||||
## 工作流
|
||||
|
||||
```
|
||||
Gitea Push Event
|
||||
↓
|
||||
Webhook Service (port 9000)
|
||||
↓
|
||||
验证 HMAC-SHA256 签名
|
||||
↓
|
||||
检查分支 (仅主分支)
|
||||
↓
|
||||
异步触发部署脚本
|
||||
↓
|
||||
执行 git pull
|
||||
↓
|
||||
执行 docker-compose build/up
|
||||
↓
|
||||
健康检查
|
||||
↓
|
||||
部署完成
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 签名验证失败
|
||||
|
||||
- 检查 Gitea 中配置的 Secret 是否与环境变量 `WEBHOOK_SECRET` 一致
|
||||
|
||||
### 部署脚本无法执行
|
||||
|
||||
- 确保 `deploy.sh` 存在于仓库根目录
|
||||
- 检查文件权限:`chmod +x deploy.sh`
|
||||
- 检查容器内是否有 git、docker 等工具
|
||||
|
||||
### Docker 操作失败
|
||||
|
||||
- 确保容器有权限访问 Docker Socket
|
||||
- 在 docker-compose 中正确挂载 Docker Socket:
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
## 示例 docker-compose 配置
|
||||
|
||||
参考项目根目录的 `docker-compose.yaml`
|
||||
@@ -1,3 +0,0 @@
|
||||
module webhook
|
||||
|
||||
go 1.21
|
||||
208
webhook/main.go
208
webhook/main.go
@@ -1,208 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GiteaPayload Gitea webhook 推送事件
|
||||
type GiteaPayload struct {
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
} `json:"repository"`
|
||||
Pusher struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"pusher"`
|
||||
Ref string `json:"ref"`
|
||||
Commit string `json:"after"`
|
||||
}
|
||||
|
||||
// Config webhook 服务配置
|
||||
type Config struct {
|
||||
Port string
|
||||
Secret string
|
||||
RepoPath string
|
||||
ScriptPath string
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
|
||||
func init() {
|
||||
// 从环境变量读取配置
|
||||
cfg.Port = os.Getenv("WEBHOOK_PORT")
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = "9000"
|
||||
}
|
||||
|
||||
cfg.Secret = os.Getenv("WEBHOOK_SECRET")
|
||||
if cfg.Secret == "" {
|
||||
cfg.Secret = "your-webhook-secret"
|
||||
}
|
||||
|
||||
cfg.RepoPath = os.Getenv("REPO_PATH")
|
||||
if cfg.RepoPath == "" {
|
||||
cfg.RepoPath = "/app"
|
||||
}
|
||||
|
||||
cfg.ScriptPath = filepath.Join(cfg.RepoPath, "deploy.sh")
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Printf("🚀 Webhook 服务启动 | 监听端口: %s\n", cfg.Port)
|
||||
log.Printf("📁 仓库路径: %s\n", cfg.RepoPath)
|
||||
log.Printf("📜 部署脚本: %s\n", cfg.ScriptPath)
|
||||
|
||||
http.HandleFunc("/webhook", handleWebhook)
|
||||
http.HandleFunc("/health", handleHealth)
|
||||
|
||||
if err := http.ListenAndServe(":"+cfg.Port, nil); err != nil {
|
||||
log.Fatalf("❌ 服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleHealth 健康检查端点
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"time": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebhook 处理 Gitea webhook
|
||||
func handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取请求体
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("❌ 读取请求体失败: %v", err)
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// 验证签名
|
||||
if !verifySignature(r.Header.Get("X-Gitea-Signature"), body) {
|
||||
log.Println("⚠️ 无效的签名")
|
||||
http.Error(w, "Invalid signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 payload
|
||||
var payload GiteaPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
log.Printf("❌ 解析 JSON 失败: %v", err)
|
||||
http.Error(w, "Failed to parse JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 只处理 master 分支的推送
|
||||
if !strings.Contains(payload.Ref, "master") && !strings.Contains(payload.Ref, "main") {
|
||||
log.Printf("⏭️ 忽略非主分支推送: %s", payload.Ref)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "非主分支,跳过部署",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ 收到有效的 Webhook 请求")
|
||||
log.Printf(" 仓库: %s", payload.Repository.FullName)
|
||||
log.Printf(" 推送者: %s", payload.Pusher.Username)
|
||||
log.Printf(" 分支: %s", payload.Ref)
|
||||
commitShort := payload.Commit
|
||||
if len(commitShort) > 7 {
|
||||
commitShort = commitShort[:7]
|
||||
}
|
||||
log.Printf(" 提交: %s", commitShort)
|
||||
|
||||
// 异步执行部署
|
||||
go executeDeployment(payload)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "部署已启动",
|
||||
"commit": payload.Commit,
|
||||
})
|
||||
}
|
||||
|
||||
// verifySignature 验证 Gitea webhook 签名
|
||||
func verifySignature(signature string, body []byte) bool {
|
||||
if cfg.Secret == "your-webhook-secret" {
|
||||
log.Println("⚠️ 警告: 使用默认 secret,建议设置 WEBHOOK_SECRET 环境变量")
|
||||
return true
|
||||
}
|
||||
|
||||
hash := hmac.New(sha256.New, []byte(cfg.Secret))
|
||||
hash.Write(body)
|
||||
expected := "sha256=" + hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
return signature == expected
|
||||
}
|
||||
|
||||
// executeDeployment 执行部署
|
||||
func executeDeployment(payload GiteaPayload) {
|
||||
log.Println("\n🔄 开始部署流程...")
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 检查脚本是否存在
|
||||
if _, err := os.Stat(cfg.ScriptPath); os.IsNotExist(err) {
|
||||
log.Printf("❌ 部署脚本不存在: %s", cfg.ScriptPath)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行部署脚本
|
||||
cmd := exec.Command("bash", cfg.ScriptPath)
|
||||
cmd.Dir = cfg.RepoPath
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
log.Printf("📜 执行部署脚本: %s", cfg.ScriptPath)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("❌ 部署脚本执行失败: %v", err)
|
||||
if stderr.Len() > 0 {
|
||||
log.Printf("错误输出:\n%s", stderr.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
duration := time.Since(startTime).Seconds()
|
||||
|
||||
log.Printf("✅ 部署完成")
|
||||
log.Printf(" 耗时: %.2f 秒", duration)
|
||||
|
||||
if stdout.Len() > 0 {
|
||||
log.Printf("输出:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
log.Printf("📝 部署日志:\n")
|
||||
log.Printf(" 仓库: %s", payload.Repository.FullName)
|
||||
log.Printf(" 提交: %s", payload.Commit[:7])
|
||||
log.Printf(" 分支: %s", payload.Ref)
|
||||
log.Printf(" 推送者: %s", payload.Pusher.Username)
|
||||
log.Printf(" 时间: %s", time.Now().Format(time.RFC3339))
|
||||
}
|
||||
Reference in New Issue
Block a user