Compare commits
21 Commits
6e3756b2e1
...
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 |
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
|
||||||
313
AGENTS.md
313
AGENTS.md
@@ -1,254 +1,127 @@
|
|||||||
# AGENTS.md - AI Coding Agent Guidelines
|
# AGENTS.md - AI Coding Agent Guidelines
|
||||||
|
|
||||||
This document provides guidelines for AI coding agents working on the BillAI project.
|
Guidelines for AI coding agents working on BillAI - a microservices bill analysis system.
|
||||||
|
|
||||||
## Project Overview
|
## 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)
|
||||||
|
|
||||||
BillAI is a microservices-based personal bill analysis system supporting WeChat and Alipay bill parsing, intelligent categorization, and visualization.
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
**Architecture:**
|
|
||||||
- `web/` - Frontend (SvelteKit 5 + TailwindCSS 4.x + TypeScript)
|
|
||||||
- `server/` - Backend API (Go 1.21 + Gin + MongoDB)
|
|
||||||
- `analyzer/` - Python analysis service (Python 3.12 + FastAPI)
|
|
||||||
|
|
||||||
## Build, Lint, and Test Commands
|
|
||||||
|
|
||||||
### Frontend (web/)
|
### Frontend (web/)
|
||||||
|
**Working Directory:** `/Users/clz/Projects/BillAI/web`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
npm run dev # Start dev server
|
||||||
npm run dev # Start dev server (Vite)
|
npm run build # Production build
|
||||||
|
npm run check # TypeScript check (svelte-check)
|
||||||
# Build
|
npm run lint # Prettier + ESLint
|
||||||
npm run build # Production build
|
npm run format # Format code (Prettier)
|
||||||
npm run preview # Preview production build
|
npm run test:unit # Run all unit tests (Vitest)
|
||||||
|
npx vitest run src/routes/+page.spec.ts # Run single test file
|
||||||
# Type checking
|
npx vitest run -t "test name" # Run test by name pattern
|
||||||
npm run check # svelte-check with TypeScript
|
|
||||||
npm run check:watch # Watch mode
|
|
||||||
|
|
||||||
# Linting and formatting
|
|
||||||
npm run lint # Prettier check + ESLint
|
|
||||||
npm run format # Format with Prettier
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
npm run test # Run all tests once
|
|
||||||
npm run test:unit # Run tests in watch mode
|
|
||||||
npx vitest run src/demo.spec.ts # Run single test file
|
|
||||||
npx vitest run -t "sum test" # Run tests matching name
|
|
||||||
npx vitest run src/routes/page.svelte.spec.ts # Run component test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (server/)
|
### Backend (server/)
|
||||||
|
**Working Directory:** `/Users/clz/Projects/BillAI/server`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run
|
go run . # Start server
|
||||||
go run . # Start development server
|
go build -o server . # Build binary
|
||||||
|
go mod tidy # Clean dependencies
|
||||||
# Build
|
go test ./... # Run all tests
|
||||||
go build . # Build binary
|
go test ./handler/... # Run package tests
|
||||||
|
go test -run TestName # Run single test function
|
||||||
# Dependencies
|
go test -v ./handler/... # Run tests with verbose output
|
||||||
go mod download # Install dependencies
|
|
||||||
go mod tidy # Clean up dependencies
|
|
||||||
|
|
||||||
# Testing (if tests exist)
|
|
||||||
go test ./... # Run all tests
|
|
||||||
go test ./handler/... # Run tests in specific package
|
|
||||||
go test -run TestName # Run single test by name
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Analyzer (analyzer/)
|
### Analyzer (analyzer/)
|
||||||
|
**Working Directory:** `/Users/clz/Projects/BillAI/analyzer`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Setup
|
python server.py # Start FastAPI server directly
|
||||||
python -m venv venv
|
uvicorn server:app --reload # Start with hot reload
|
||||||
pip install -r requirements.txt
|
pytest # Run all tests
|
||||||
|
pytest test_file.py # Run single test file
|
||||||
# Run
|
pytest -k "test_name" # Run test by name pattern
|
||||||
python server.py # Start FastAPI server
|
pip install -r requirements.txt # Install dependencies
|
||||||
|
|
||||||
# Testing (if tests exist)
|
|
||||||
pytest # Run all tests
|
|
||||||
pytest test_file.py # Run single test file
|
|
||||||
pytest -k "test_name" # Run tests matching name
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
**Working Directory:** `/Users/clz/Projects/BillAI`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d --build # Start all services
|
docker-compose up -d --build # Start/rebuild all services
|
||||||
docker-compose ps # Check service status
|
docker-compose logs -f server # Follow service logs
|
||||||
docker-compose down # Stop all services
|
docker-compose down # Stop all services
|
||||||
docker-compose logs -f web # Follow logs for specific service
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style
|
||||||
|
|
||||||
### TypeScript/Svelte (Frontend)
|
### 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`.
|
||||||
|
|
||||||
**Formatting (Prettier):**
|
### TypeScript/Svelte (web/)
|
||||||
- Use tabs for indentation
|
- **Formatting:** Prettier (Tabs, single quotes, no trailing commas, printWidth 100).
|
||||||
- Single quotes for strings
|
- **Naming:** `PascalCase` for types/components/interfaces, `camelCase` for variables/functions.
|
||||||
- No trailing commas
|
- **Imports:** Use `$lib` alias for internal imports.
|
||||||
- Print width: 100 characters
|
```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.
|
||||||
|
|
||||||
**Imports:**
|
### Go Backend (server/)
|
||||||
- Use `$lib/` alias for imports from `src/lib/`
|
- **Structure:** `handler` (HTTP) → `service` (Logic) → `repository` (DB) → `model` (Structs).
|
||||||
- Use `$app/` for SvelteKit internals
|
- **Tags:** Use `json` (snake_case) and `form` tags. Use `omitempty` for optional fields.
|
||||||
- Group imports: external packages, then internal modules
|
```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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
```typescript
|
### Python Analyzer (analyzer/)
|
||||||
import { browser } from '$app/environment';
|
- **Style:** PEP 8. Use `snake_case` for variables/functions.
|
||||||
import { auth } from '$lib/stores/auth';
|
- **Type Hints:** Mandatory for function arguments and return types.
|
||||||
import type { UIBill } from '$lib/models/bill';
|
- **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.
|
||||||
|
|
||||||
**Types:**
|
## Key Patterns
|
||||||
- Define interfaces for API responses and requests
|
|
||||||
- Use `type` for unions and simple type aliases
|
|
||||||
- Export types from dedicated files in `$lib/types/` or alongside models
|
|
||||||
|
|
||||||
```typescript
|
- **API Flow:**
|
||||||
export interface UploadResponse {
|
- Frontend talks to `server` (Go) via `/api` proxy.
|
||||||
result: boolean;
|
- `server` handles auth, DB operations, and delegates complex file processing to `analyzer` (Python).
|
||||||
message: string;
|
- `analyzer` cleanses CSV/Excel files and returns structured JSON/CSV to `server`.
|
||||||
data?: UploadData;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Naming Conventions:**
|
- **Authentication:**
|
||||||
- PascalCase: Components, interfaces, types
|
- JWT based. Token stored in frontend.
|
||||||
- camelCase: Functions, variables, properties
|
- Header: `Authorization: Bearer <token>`.
|
||||||
- Use descriptive names: `fetchBills`, `UIBill`, `checkHealth`
|
- Backend middleware checks token. 401 triggers logout/redirect.
|
||||||
|
|
||||||
**Error Handling:**
|
- **File Processing:**
|
||||||
- Wrap API calls in try/catch
|
- Flow: Upload (ZIP/XLSX) -> Extract/Convert (to UTF-8 CSV) -> Clean (normalize columns) -> Import to DB.
|
||||||
- Throw `Error` with HTTP status for API failures
|
- `analyzer` uses `openpyxl` for Excel and regex for cleaning text.
|
||||||
- Handle 401 responses with logout redirect
|
|
||||||
|
|
||||||
```typescript
|
## Important Files
|
||||||
if (!response.ok) {
|
- `web/src/lib/api.ts` - Centralized API client methods.
|
||||||
throw new Error(`HTTP ${response.status}`);
|
- `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.
|
||||||
### Go (Backend)
|
- `analyzer/cleaners/*.py` - Specific logic for Alipay/Wechat/JD bills.
|
||||||
|
|
||||||
**Project Structure:**
|
|
||||||
- `handler/` - HTTP request handlers
|
|
||||||
- `service/` - Business logic
|
|
||||||
- `repository/` - Data access layer
|
|
||||||
- `model/` - Data structures
|
|
||||||
- `adapter/` - External service integrations
|
|
||||||
- `config/` - Configuration management
|
|
||||||
- `middleware/` - Auth and other middleware
|
|
||||||
|
|
||||||
**Naming Conventions:**
|
|
||||||
- PascalCase: Exported types, functions, constants
|
|
||||||
- camelCase: Unexported functions, variables
|
|
||||||
- Use descriptive names: `UpdateBillRequest`, `parseBillTime`
|
|
||||||
|
|
||||||
**Error Handling:**
|
|
||||||
- Define sentinel errors in `repository/errors.go`
|
|
||||||
- Return errors up the call stack
|
|
||||||
- Use structured JSON responses for HTTP errors
|
|
||||||
|
|
||||||
```go
|
|
||||||
if err == repository.ErrNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, Response{Result: false, Message: "not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**JSON Tags:**
|
|
||||||
- Use snake_case for JSON field names
|
|
||||||
- Use `omitempty` for optional fields
|
|
||||||
- Match frontend API expectations
|
|
||||||
|
|
||||||
```go
|
|
||||||
type UpdateBillRequest struct {
|
|
||||||
Category *string `json:"category,omitempty"`
|
|
||||||
Amount *float64 `json:"amount,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response Format:**
|
|
||||||
- All API responses use consistent structure:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Response struct {
|
|
||||||
Result bool `json:"result"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Data interface{} `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python (Analyzer)
|
|
||||||
|
|
||||||
**Style:**
|
|
||||||
- Follow PEP 8
|
|
||||||
- Use type hints for function signatures
|
|
||||||
- Use Pydantic models for request/response validation
|
|
||||||
|
|
||||||
```python
|
|
||||||
def do_clean(
|
|
||||||
input_path: str,
|
|
||||||
output_path: str,
|
|
||||||
bill_type: str = "auto"
|
|
||||||
) -> tuple[bool, str, str]:
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Handling:**
|
|
||||||
- Raise `HTTPException` for API errors
|
|
||||||
- Use try/except for file operations
|
|
||||||
- Return structured responses
|
|
||||||
|
|
||||||
```python
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=400, detail=message)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
**Frontend Tests:**
|
|
||||||
- Use Vitest with Playwright for browser testing
|
|
||||||
- Component tests: `*.svelte.spec.ts`
|
|
||||||
- Unit tests: `*.spec.ts`
|
|
||||||
- Tests require assertions: `expect.assertions()` or explicit expects
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { render } from 'vitest-browser-svelte';
|
|
||||||
|
|
||||||
describe('/+page.svelte', () => {
|
|
||||||
it('should render h1', async () => {
|
|
||||||
render(Page);
|
|
||||||
await expect.element(page.getByRole('heading')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Patterns
|
|
||||||
|
|
||||||
**API Communication:**
|
|
||||||
- Frontend proxies API calls through SvelteKit to avoid CORS
|
|
||||||
- Backend uses Gin framework with JSON responses
|
|
||||||
- Analyzer communicates via HTTP (preferred) or subprocess
|
|
||||||
|
|
||||||
**Data Flow:**
|
|
||||||
- Frontend (SvelteKit) -> Backend (Go/Gin) -> MongoDB
|
|
||||||
- Backend -> Analyzer (Python/FastAPI) for bill parsing
|
|
||||||
|
|
||||||
**Authentication:**
|
|
||||||
- JWT tokens stored in frontend auth store
|
|
||||||
- Bearer token sent in Authorization header
|
|
||||||
- 401 responses trigger logout and redirect
|
|
||||||
|
|
||||||
## File Locations
|
|
||||||
|
|
||||||
- API types: `web/src/lib/api.ts`
|
|
||||||
- UI models: `web/src/lib/models/`
|
|
||||||
- Go handlers: `server/handler/`
|
|
||||||
- Go models: `server/model/`
|
|
||||||
- Python API: `analyzer/server.py`
|
|
||||||
|
|||||||
59
CHANGELOG.md
59
CHANGELOG.md
@@ -5,6 +5,49 @@
|
|||||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
版本号遵循 [语义化版本](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
|
## [1.2.0] - 2026-01-25
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
@@ -19,6 +62,22 @@
|
|||||||
- 新增 `DELETE /api/bills/:id` 接口
|
- 新增 `DELETE /api/bills/:id` 接口
|
||||||
- `BillDetailDrawer` 组件新增 `allowDelete` 和 `onDelete` props
|
- `BillDetailDrawer` 组件新增 `allowDelete` 和 `onDelete` props
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-01-23
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
- **智能复核快捷确认** - 在复核列表每行添加快捷确认按钮
|
||||||
|
- 无需打开详情页面即可确认分类正确
|
||||||
|
- 点击确认按钮立即清除复核标记并从列表移除
|
||||||
|
- 自动更新统计数据(总数、高优先级、低优先级计数)
|
||||||
|
- 按钮支持加载状态显示,防止重复操作
|
||||||
|
- 提升复核效率,支持快速批量确认
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- **AGENTS.md 更新** - 精简为 150 行,专为 AI 编程助手设计
|
||||||
|
- 核心构建/测试/lint 命令说明
|
||||||
|
- TypeScript、Go、Python 代码风格指南
|
||||||
|
- 关键架构模式和文件位置
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-23
|
## [1.1.0] - 2026-01-23
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
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 实时状态推送**
|
- [ ] **SSE 实时状态推送**
|
||||||
- 服务器实现 `/events` SSE 端点
|
- 服务器实现 `/events` SSE 端点
|
||||||
- 前端使用 EventSource 接收状态
|
- 前端使用 EventSource 接收状态
|
||||||
|
|||||||
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 .base import BaseCleaner
|
||||||
from .alipay import AlipayCleaner
|
from .alipay import AlipayCleaner
|
||||||
from .wechat import WechatCleaner
|
from .wechat import WechatCleaner
|
||||||
|
from .jd import JDCleaner
|
||||||
|
|
||||||
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner']
|
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner', 'JDCleaner']
|
||||||
|
|
||||||
|
|||||||
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妍丽美妆店
|
- 妍丽 # AFIONA妍丽美妆店
|
||||||
- 屈臣氏
|
- 屈臣氏
|
||||||
- 丝芙兰
|
- 丝芙兰
|
||||||
|
- 保鲜盒
|
||||||
|
- 收纳
|
||||||
|
- 厨房
|
||||||
|
- 清洁
|
||||||
|
- 洗衣
|
||||||
|
- 纸巾
|
||||||
|
- 毛巾
|
||||||
|
- 床品
|
||||||
|
- 家居
|
||||||
|
|
||||||
# 餐饮美食
|
# 餐饮美食
|
||||||
餐饮美食:
|
餐饮美食:
|
||||||
|
|||||||
48
analyzer/config/category_jd.yaml
Normal file
48
analyzer/config/category_jd.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 京东账单分类映射配置
|
||||||
|
# 将京东原始分类转换为统一分类
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 京东原始分类 -> 统一分类映射
|
||||||
|
# 京东账单中的"交易分类"字段可能包含以下值:
|
||||||
|
# - 余额、小金库、白条:财务操作(已在清洗时过滤)
|
||||||
|
# - 其他、其他网购、网购:需要根据商品说明进一步判断
|
||||||
|
# - 食品酒饮:餐饮美食
|
||||||
|
# - 数码电器、电脑办公:数码电器
|
||||||
|
# - 日用百货:日用百货
|
||||||
|
# - 图书文娱:文化休闲
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
分类映射:
|
||||||
|
# 直接映射(京东分类 -> 统一分类)
|
||||||
|
食品酒饮: 餐饮美食
|
||||||
|
数码电器: 数码电器
|
||||||
|
电脑办公: 数码电器
|
||||||
|
日用百货: 日用百货
|
||||||
|
图书文娱: 文化休闲
|
||||||
|
|
||||||
|
# 需要进一步判断的分类(返回空字符串,由关键词推断)
|
||||||
|
其他: ""
|
||||||
|
其他网购: ""
|
||||||
|
网购: ""
|
||||||
|
|
||||||
|
# 财务类(通常已被过滤,但以防万一)
|
||||||
|
余额: ""
|
||||||
|
小金库: ""
|
||||||
|
白条: ""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 商户名称 -> 统一分类映射
|
||||||
|
# 根据商户名称直接映射分类,无需关键词匹配
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
商户映射:
|
||||||
|
京东外卖: 餐饮美食
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 默认分类
|
||||||
|
# 当无法匹配任何规则时使用
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
默认分类: 其他支出
|
||||||
@@ -49,7 +49,7 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
|
|||||||
从内容和文件名检测账单类型
|
从内容和文件名检测账单类型
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
'alipay', 'wechat', 或 ''
|
'alipay', 'wechat', 'jd', 或 ''
|
||||||
"""
|
"""
|
||||||
# 从文件名检测
|
# 从文件名检测
|
||||||
filename_lower = filename.lower()
|
filename_lower = filename.lower()
|
||||||
@@ -57,6 +57,8 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
|
|||||||
return 'alipay'
|
return 'alipay'
|
||||||
if '微信' in filename or 'wechat' in filename_lower:
|
if '微信' in filename or 'wechat' in filename_lower:
|
||||||
return 'wechat'
|
return 'wechat'
|
||||||
|
if '京东' in filename or 'jd' in filename_lower:
|
||||||
|
return 'jd'
|
||||||
|
|
||||||
# 从内容检测
|
# 从内容检测
|
||||||
# 支付宝特征: 有 "交易分类" 和 "对方账号" 列
|
# 支付宝特征: 有 "交易分类" 和 "对方账号" 列
|
||||||
@@ -67,6 +69,12 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
|
|||||||
if '交易类型' in content and '金额(元)' in content:
|
if '交易类型' in content and '金额(元)' in content:
|
||||||
return 'wechat'
|
return 'wechat'
|
||||||
|
|
||||||
|
# 京东特征: 有 "商户名称" 和 "交易说明" 列,或头部包含 "京东账号名"
|
||||||
|
if '商户名称' in content and '交易说明' in content:
|
||||||
|
return 'jd'
|
||||||
|
if '京东账号名' in content:
|
||||||
|
return 'jd'
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ if sys.stdout.encoding != 'utf-8':
|
|||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||||
|
|
||||||
from cleaners.base import compute_date_range_from_values
|
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 category import infer_category, get_all_categories, get_all_income_categories
|
||||||
from converter import convert_bill_file
|
from converter import convert_bill_file
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class CleanRequest(BaseModel):
|
|||||||
start: Optional[str] = None
|
start: Optional[str] = None
|
||||||
end: Optional[str] = None
|
end: Optional[str] = None
|
||||||
format: Optional[str] = "csv"
|
format: Optional[str] = "csv"
|
||||||
bill_type: Optional[str] = "auto" # auto, alipay, wechat
|
bill_type: Optional[str] = "auto" # auto, alipay, wechat, jd
|
||||||
|
|
||||||
|
|
||||||
class CleanResponse(BaseModel):
|
class CleanResponse(BaseModel):
|
||||||
@@ -90,7 +90,7 @@ def detect_bill_type(filepath: str) -> str | None:
|
|||||||
检测账单类型
|
检测账单类型
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
'alipay' | 'wechat' | None
|
'alipay' | 'wechat' | 'jd' | None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(filepath, "r", encoding="utf-8") as f:
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
@@ -107,6 +107,14 @@ def detect_bill_type(filepath: str) -> str | None:
|
|||||||
if "交易类型" in line and "金额(元)" in line:
|
if "交易类型" in line and "金额(元)" in line:
|
||||||
return "wechat"
|
return "wechat"
|
||||||
|
|
||||||
|
# 京东特征:表头包含 "商户名称" 和 "交易说明"
|
||||||
|
if "商户名称" in line and "交易说明" in line:
|
||||||
|
return "jd"
|
||||||
|
|
||||||
|
# 京东特征:头部信息包含 "京东账号名"
|
||||||
|
if "京东账号名" in line:
|
||||||
|
return "jd"
|
||||||
|
|
||||||
# 数据行特征
|
# 数据行特征
|
||||||
if line.startswith("202"):
|
if line.startswith("202"):
|
||||||
if "¥" in line:
|
if "¥" in line:
|
||||||
@@ -155,14 +163,16 @@ def do_clean(
|
|||||||
try:
|
try:
|
||||||
if bill_type == "alipay":
|
if bill_type == "alipay":
|
||||||
cleaner = AlipayCleaner(input_path, output_path, output_format)
|
cleaner = AlipayCleaner(input_path, output_path, output_format)
|
||||||
|
elif bill_type == "jd":
|
||||||
|
cleaner = JDCleaner(input_path, output_path, output_format)
|
||||||
else:
|
else:
|
||||||
cleaner = WechatCleaner(input_path, output_path, output_format)
|
cleaner = WechatCleaner(input_path, output_path, output_format)
|
||||||
|
|
||||||
cleaner.set_date_range(start_date, end_date)
|
cleaner.set_date_range(start_date, end_date)
|
||||||
cleaner.clean()
|
cleaner.clean()
|
||||||
|
|
||||||
type_names = {"alipay": "支付宝", "wechat": "微信"}
|
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
|
||||||
return True, bill_type, f"✅ {type_names[bill_type]}账单清洗完成"
|
return True, bill_type, f"✅ {type_names.get(bill_type, bill_type)}账单清洗完成"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, bill_type, f"清洗失败: {str(e)}"
|
return False, bill_type, f"清洗失败: {str(e)}"
|
||||||
@@ -324,7 +334,7 @@ async def detect_bill_type_api(file: UploadFile = File(...)):
|
|||||||
"""
|
"""
|
||||||
检测账单类型
|
检测账单类型
|
||||||
|
|
||||||
上传文件后自动检测是支付宝还是微信账单
|
上传文件后自动检测是支付宝、微信还是京东账单
|
||||||
"""
|
"""
|
||||||
suffix = Path(file.filename).suffix or ".csv"
|
suffix = Path(file.filename).suffix or ".csv"
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
@@ -336,10 +346,10 @@ async def detect_bill_type_api(file: UploadFile = File(...)):
|
|||||||
if bill_type is None:
|
if bill_type is None:
|
||||||
raise HTTPException(status_code=400, detail="无法识别账单类型")
|
raise HTTPException(status_code=400, detail="无法识别账单类型")
|
||||||
|
|
||||||
type_names = {"alipay": "支付宝", "wechat": "微信"}
|
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
|
||||||
return {
|
return {
|
||||||
"bill_type": bill_type,
|
"bill_type": bill_type,
|
||||||
"display_name": type_names[bill_type]
|
"display_name": type_names.get(bill_type, bill_type)
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(tmp_path):
|
if os.path.exists(tmp_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()
|
||||||
62
deploy.sh
Normal file
62
deploy.sh
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# BillAI 部署脚本
|
||||||
|
# 用于手动部署或 Gitea Actions 自动部署
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
fi
|
||||||
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
|
||||||
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.
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,14 +13,14 @@ type CleanOptions struct {
|
|||||||
|
|
||||||
// CleanResult 清洗结果
|
// CleanResult 清洗结果
|
||||||
type CleanResult struct {
|
type CleanResult struct {
|
||||||
BillType string // 检测到的账单类型: alipay/wechat
|
BillType string // 检测到的账单类型: alipay/wechat/jd
|
||||||
Output string // 脚本输出信息
|
Output string // 脚本输出信息
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertResult 格式转换结果
|
// ConvertResult 格式转换结果
|
||||||
type ConvertResult struct {
|
type ConvertResult struct {
|
||||||
OutputPath string // 转换后的文件路径
|
OutputPath string // 转换后的文件路径
|
||||||
BillType string // 检测到的账单类型: alipay/wechat
|
BillType string // 检测到的账单类型: alipay/wechat/jd
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleaner 账单清洗器接口
|
// Cleaner 账单清洗器接口
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ func detectBillTypeFromOutput(output string) string {
|
|||||||
if strings.Contains(output, "微信") {
|
if strings.Contains(output, "微信") {
|
||||||
return "wechat"
|
return "wechat"
|
||||||
}
|
}
|
||||||
|
if strings.Contains(output, "京东") {
|
||||||
|
return "jd"
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type ListBillsRequest struct {
|
|||||||
StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD
|
StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD
|
||||||
EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD
|
EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD
|
||||||
Category string `form:"category"` // 分类筛选
|
Category string `form:"category"` // 分类筛选
|
||||||
Type string `form:"type"` // 账单类型 alipay/wechat
|
Type string `form:"type"` // 账单类型 alipay/wechat/jd
|
||||||
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
|
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type DeleteBillResponse struct {
|
|||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteBill DELETE /api/bills/:id 删除清洗后的账单记录
|
// DeleteBill POST /api/bills/:id/delete 删除清洗后的账单记录
|
||||||
func DeleteBill(c *gin.Context) {
|
func DeleteBill(c *gin.Context) {
|
||||||
id := strings.TrimSpace(c.Param("id"))
|
id := strings.TrimSpace(c.Param("id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
|
|||||||
@@ -1,61 +1,59 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"billai-server/config"
|
|
||||||
"billai-server/model"
|
"billai-server/model"
|
||||||
"billai-server/service"
|
"billai-server/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Review 获取需要复核的记录
|
// Review 获取需要复核的记录
|
||||||
func Review(c *gin.Context) {
|
func Review(c *gin.Context) {
|
||||||
// 获取文件名参数
|
// 获取数据
|
||||||
fileName := c.Query("file")
|
repo := repository.GetRepository()
|
||||||
if fileName == "" {
|
if repo == nil {
|
||||||
c.JSON(http.StatusBadRequest, model.ReviewResponse{
|
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
|
||||||
Result: false,
|
Result: false,
|
||||||
Message: "请提供文件名参数 (file)",
|
Message: "数据库未连接",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建文件路径
|
// 从MongoDB查询所有需要复核的账单
|
||||||
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
|
bills, err := repo.GetBillsNeedReview()
|
||||||
filePath := filepath.Join(outputDirAbs, fileName)
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
|
||||||
// 检查文件是否存在
|
|
||||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
|
||||||
c.JSON(http.StatusNotFound, model.ReviewResponse{
|
|
||||||
Result: false,
|
Result: false,
|
||||||
Message: "文件不存在: " + fileName,
|
Message: "查询失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断文件格式
|
// 统计高低优先级数量并转换为 ReviewRecord
|
||||||
format := "csv"
|
|
||||||
if strings.HasSuffix(fileName, ".json") {
|
|
||||||
format = "json"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取需要复核的记录
|
|
||||||
records := service.ExtractNeedsReview(filePath, format)
|
|
||||||
|
|
||||||
// 统计高低优先级数量
|
|
||||||
highCount := 0
|
highCount := 0
|
||||||
lowCount := 0
|
lowCount := 0
|
||||||
for _, r := range records {
|
records := make([]model.ReviewRecord, 0, len(bills))
|
||||||
if r.ReviewLevel == "HIGH" {
|
|
||||||
|
for _, bill := range bills {
|
||||||
|
if bill.ReviewLevel == "HIGH" {
|
||||||
highCount++
|
highCount++
|
||||||
} else if r.ReviewLevel == "LOW" {
|
} else if bill.ReviewLevel == "LOW" {
|
||||||
lowCount++
|
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{
|
c.JSON(http.StatusOK, model.ReviewResponse{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"billai-server/config"
|
"billai-server/config"
|
||||||
"billai-server/model"
|
"billai-server/model"
|
||||||
|
"billai-server/repository"
|
||||||
"billai-server/service"
|
"billai-server/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,6 +146,8 @@ func Upload(c *gin.Context) {
|
|||||||
billType = "alipay"
|
billType = "alipay"
|
||||||
} else if strings.Contains(fileName, "微信") || strings.Contains(fileName, "wechat") {
|
} else if strings.Contains(fileName, "微信") || strings.Contains(fileName, "wechat") {
|
||||||
billType = "wechat"
|
billType = "wechat"
|
||||||
|
} else if strings.Contains(fileName, "京东") || strings.Contains(fileName, "jd") {
|
||||||
|
billType = "jd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if billType == "" {
|
if billType == "" {
|
||||||
@@ -152,15 +155,15 @@ func Upload(c *gin.Context) {
|
|||||||
service.CleanupExtractedFiles(extractedFiles)
|
service.CleanupExtractedFiles(extractedFiles)
|
||||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||||
Result: false,
|
Result: false,
|
||||||
Message: "无法识别账单类型,请指定 type 参数 (alipay 或 wechat)",
|
Message: "无法识别账单类型,请指定 type 参数 (alipay/wechat/jd)",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if billType != "alipay" && billType != "wechat" {
|
if billType != "alipay" && billType != "wechat" && billType != "jd" {
|
||||||
service.CleanupExtractedFiles(extractedFiles)
|
service.CleanupExtractedFiles(extractedFiles)
|
||||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||||
Result: false,
|
Result: false,
|
||||||
Message: "账单类型无效,仅支持 alipay 或 wechat",
|
Message: "账单类型无效,仅支持 alipay/wechat/jd",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -252,22 +255,41 @@ func Upload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
service.CleanupExtractedFiles(extractedFiles)
|
service.CleanupExtractedFiles(extractedFiles)
|
||||||
|
|
||||||
// 13. 返回成功响应
|
// 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)
|
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
|
||||||
if dedupResult.DuplicateCount > 0 {
|
if dedupResult.DuplicateCount > 0 {
|
||||||
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
||||||
}
|
}
|
||||||
|
if jdRelatedDeleted > 0 {
|
||||||
|
message = fmt.Sprintf("%s,标记删除 %d 条重复的京东订单", message, jdRelatedDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.UploadResponse{
|
c.JSON(http.StatusOK, model.UploadResponse{
|
||||||
Result: true,
|
Result: true,
|
||||||
Message: message,
|
Message: message,
|
||||||
Data: &model.UploadData{
|
Data: &model.UploadData{
|
||||||
BillType: billType,
|
BillType: billType,
|
||||||
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
||||||
FileName: outputFileName,
|
FileName: outputFileName,
|
||||||
RawCount: rawCount,
|
RawCount: rawCount,
|
||||||
CleanedCount: cleanedCount,
|
CleanedCount: cleanedCount,
|
||||||
DuplicateCount: dedupResult.DuplicateCount,
|
DuplicateCount: dedupResult.DuplicateCount,
|
||||||
|
JDRelatedDeleted: jdRelatedDeleted,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func (t LocalTime) Time() time.Time {
|
|||||||
// RawBill 原始账单记录(存储上传的原始数据)
|
// RawBill 原始账单记录(存储上传的原始数据)
|
||||||
type RawBill struct {
|
type RawBill struct {
|
||||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
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"` // 来源文件名
|
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
||||||
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
||||||
RowIndex int `bson:"row_index" json:"row_index"` // 原始行号
|
RowIndex int `bson:"row_index" json:"row_index"` // 原始行号
|
||||||
@@ -81,7 +81,7 @@ type RawBill struct {
|
|||||||
// CleanedBill 清洗后账单记录(标准化后的数据)
|
// CleanedBill 清洗后账单记录(标准化后的数据)
|
||||||
type CleanedBill struct {
|
type CleanedBill struct {
|
||||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
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"` // 交易订单号(用于去重)
|
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
|
||||||
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
|
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
|
||||||
Time LocalTime `bson:"time" json:"time"` // 交易时间(本地时间格式)
|
Time LocalTime `bson:"time" json:"time"` // 交易时间(本地时间格式)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package model
|
|||||||
|
|
||||||
// UploadRequest 上传请求参数
|
// UploadRequest 上传请求参数
|
||||||
type UploadRequest struct {
|
type UploadRequest struct {
|
||||||
Type string `form:"type"` // 账单类型: alipay/wechat(可选,会自动检测)
|
Type string `form:"type"` // 账单类型: alipay/wechat/jd(可选,会自动检测)
|
||||||
Password string `form:"password"` // ZIP 文件密码(可选)
|
Password string `form:"password"` // ZIP 文件密码(可选)
|
||||||
Year string `form:"year"` // 年份筛选
|
Year string `form:"year"` // 年份筛选
|
||||||
Month string `form:"month"` // 月份筛选
|
Month string `form:"month"` // 月份筛选
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package model
|
|||||||
|
|
||||||
// UploadData 上传响应数据
|
// UploadData 上传响应数据
|
||||||
type UploadData struct {
|
type UploadData struct {
|
||||||
BillType string `json:"bill_type,omitempty"` // alipay/wechat
|
BillType string `json:"bill_type,omitempty"` // alipay/wechat/jd
|
||||||
FileURL string `json:"file_url,omitempty"` // 下载链接
|
FileURL string `json:"file_url,omitempty"` // 下载链接
|
||||||
FileName string `json:"file_name,omitempty"` // 文件名
|
FileName string `json:"file_name,omitempty"` // 文件名
|
||||||
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
|
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
|
||||||
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
|
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
|
||||||
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
|
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
|
||||||
|
JDRelatedDeleted int64 `json:"jd_related_deleted,omitempty"` // 软删除的京东关联记录数(其他来源中描述包含京东订单号的记录)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResponse 上传响应
|
// UploadResponse 上传响应
|
||||||
|
|||||||
@@ -445,7 +445,12 @@ func (r *Repository) DeleteCleanedBillByID(id string) error {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
filter := bson.M{"_id": oid}
|
filter := bson.M{"_id": oid}
|
||||||
update := bson.M{"$set": bson.M{"is_deleted": true}}
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"is_deleted": true,
|
||||||
|
"updated_at": time.Now(), // 记录更新时间
|
||||||
|
},
|
||||||
|
}
|
||||||
result, err := r.cleanedCollection.UpdateOne(ctx, filter, update)
|
result, err := r.cleanedCollection.UpdateOne(ctx, filter, update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("soft delete bill failed: %w", err)
|
return fmt.Errorf("soft delete bill failed: %w", err)
|
||||||
@@ -458,6 +463,41 @@ func (r *Repository) DeleteCleanedBillByID(id string) error {
|
|||||||
return nil
|
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 客户端(用于兼容旧代码)
|
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||||
func (r *Repository) GetClient() *mongo.Client {
|
func (r *Repository) GetClient() *mongo.Client {
|
||||||
return r.client
|
return r.client
|
||||||
|
|||||||
@@ -51,4 +51,9 @@ type BillRepository interface {
|
|||||||
|
|
||||||
// CountRawByField 按字段统计原始数据数量
|
// CountRawByField 按字段统计原始数据数量
|
||||||
CountRawByField(fieldName, value string) (int64, error)
|
CountRawByField(fieldName, value string) (int64, error)
|
||||||
|
|
||||||
|
// SoftDeleteJDRelatedBills 软删除描述中包含"京东-订单编号"的非京东账单
|
||||||
|
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
|
||||||
|
// 返回: 删除数量、错误
|
||||||
|
SoftDeleteJDRelatedBills() (int64, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func setupAPIRoutes(r *gin.Engine) {
|
|||||||
authed.POST("/bills/:id", handler.UpdateBill)
|
authed.POST("/bills/:id", handler.UpdateBill)
|
||||||
|
|
||||||
// 删除账单(软删除)
|
// 删除账单(软删除)
|
||||||
authed.DELETE("/bills/:id", handler.DeleteBill)
|
authed.POST("/bills/:id/delete", handler.DeleteBill)
|
||||||
|
|
||||||
// 手动创建账单
|
// 手动创建账单
|
||||||
authed.POST("/bills/manual", handler.CreateManualBills)
|
authed.POST("/bills/manual", handler.CreateManualBills)
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ func ExtractZip(zipPath, destDir, password string) (*ExtractResult, error) {
|
|||||||
result.BillType = "alipay"
|
result.BillType = "alipay"
|
||||||
} else if strings.Contains(fileName, "微信") || strings.Contains(strings.ToLower(fileName), "wechat") {
|
} else if strings.Contains(fileName, "微信") || strings.Contains(strings.ToLower(fileName), "wechat") {
|
||||||
result.BillType = "wechat"
|
result.BillType = "wechat"
|
||||||
|
} else if strings.Contains(fileName, "京东") || strings.Contains(strings.ToLower(fileName), "jd") {
|
||||||
|
result.BillType = "jd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ func detectBillTypeAndIdField(header []string) (billType string, idFieldIdx int)
|
|||||||
if col == "交易类型" || col == "金额(元)" {
|
if col == "交易类型" || col == "金额(元)" {
|
||||||
billType = "wechat"
|
billType = "wechat"
|
||||||
}
|
}
|
||||||
|
// 京东特征
|
||||||
|
if col == "商户名称" || col == "交易说明" {
|
||||||
|
billType = "jd"
|
||||||
|
}
|
||||||
|
|
||||||
// 查找去重字段(优先使用交易订单号/交易号)
|
// 查找去重字段(优先使用交易订单号/交易号)
|
||||||
if col == "交易订单号" || col == "交易号" || col == "交易单号" {
|
if col == "交易订单号" || col == "交易号" || col == "交易单号" {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ func DetectBillTypeFromOutput(output string) string {
|
|||||||
if containsSubstring(output, "微信") {
|
if containsSubstring(output, "微信") {
|
||||||
return "wechat"
|
return "wechat"
|
||||||
}
|
}
|
||||||
|
if containsSubstring(output, "京东") {
|
||||||
|
return "jd"
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
web/.dockerignore
Normal file
5
web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.3.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"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 PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vite 注入的全局变量
|
||||||
|
const __APP_VERSION__: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function checkHealth(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
export type BillType = 'alipay' | 'wechat';
|
export type BillType = 'alipay' | 'wechat' | 'jd';
|
||||||
|
|
||||||
export interface UploadData {
|
export interface UploadData {
|
||||||
bill_type: BillType;
|
bill_type: BillType;
|
||||||
@@ -128,17 +128,6 @@ export async function uploadBill(
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取复核记录
|
|
||||||
export async function getReviewRecords(fileName: string): Promise<ReviewResponse> {
|
|
||||||
const response = await apiFetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取月度统计(全部数据,不受筛选条件影响)
|
// 获取月度统计(全部数据,不受筛选条件影响)
|
||||||
export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
|
export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
|
||||||
const response = await apiFetch(`${API_BASE}/api/monthly-stats`);
|
const response = await apiFetch(`${API_BASE}/api/monthly-stats`);
|
||||||
@@ -403,8 +392,8 @@ export interface DeleteBillResponse {
|
|||||||
|
|
||||||
// 删除账单(软删除)
|
// 删除账单(软删除)
|
||||||
export async function deleteBill(id: string): Promise<DeleteBillResponse> {
|
export async function deleteBill(id: string): Promise<DeleteBillResponse> {
|
||||||
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
|
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}/delete`, {
|
||||||
method: 'DELETE'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
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>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||||
|
onDelete?: (deleted: UIBill) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
showDescription = true,
|
showDescription = true,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
categories = [],
|
categories = [],
|
||||||
onUpdate
|
onUpdate,
|
||||||
|
onDelete
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// 排序状态
|
// 排序状态
|
||||||
@@ -112,6 +114,24 @@
|
|||||||
onUpdate?.(updated, original);
|
onUpdate?.(updated, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRecordDeleted(deleted: UIBill) {
|
||||||
|
const idx = records.findIndex(r => r === deleted);
|
||||||
|
const finalIdx = idx !== -1
|
||||||
|
? idx
|
||||||
|
: records.findIndex(r =>
|
||||||
|
r.time === deleted.time &&
|
||||||
|
r.merchant === deleted.merchant &&
|
||||||
|
r.amount === deleted.amount
|
||||||
|
);
|
||||||
|
|
||||||
|
if (finalIdx !== -1) {
|
||||||
|
records.splice(finalIdx, 1);
|
||||||
|
records = [...records];
|
||||||
|
}
|
||||||
|
|
||||||
|
onDelete?.(deleted);
|
||||||
|
}
|
||||||
|
|
||||||
// 重置分页(当记录变化时)
|
// 重置分页(当记录变化时)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
records;
|
records;
|
||||||
@@ -280,4 +300,6 @@
|
|||||||
viewDescription="查看这笔支出的详细信息"
|
viewDescription="查看这笔支出的详细信息"
|
||||||
editDescription="修改这笔支出的信息"
|
editDescription="修改这笔支出的信息"
|
||||||
onUpdate={handleRecordUpdated}
|
onUpdate={handleRecordUpdated}
|
||||||
|
onDelete={handleRecordDeleted}
|
||||||
|
allowDelete={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,9 +18,10 @@
|
|||||||
records: UIBill[];
|
records: UIBill[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||||
|
onDelete?: (deleted: UIBill) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { records = $bindable(), categories = [], onUpdate }: Props = $props();
|
let { records = $bindable(), categories = [], onUpdate, onDelete }: Props = $props();
|
||||||
|
|
||||||
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||||
// 更新 records 数组
|
// 更新 records 数组
|
||||||
@@ -46,6 +47,28 @@
|
|||||||
// 传播到父组件
|
// 传播到父组件
|
||||||
onUpdate?.(updated, original);
|
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 状态
|
// Dialog 状态
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
@@ -923,6 +946,7 @@
|
|||||||
pageSize={8}
|
pageSize={8}
|
||||||
{categories}
|
{categories}
|
||||||
onUpdate={handleRecordUpdated}
|
onUpdate={handleRecordUpdated}
|
||||||
|
onDelete={handleRecordDeleted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
records: UIBill[];
|
records: UIBill[];
|
||||||
categories: string[]; // 可用的分类列表
|
categories: string[]; // 可用的分类列表
|
||||||
onUpdate?: (record: UIBill) => 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 dialogOpen = $state(false);
|
||||||
let selectedRecord = $state<UIBill | null>(null);
|
let selectedRecord = $state<UIBill | null>(null);
|
||||||
@@ -32,6 +33,26 @@
|
|||||||
selectedRecord = updated;
|
selectedRecord = updated;
|
||||||
onUpdate?.(updated);
|
onUpdate?.(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRecordDeleted(deleted: UIBill) {
|
||||||
|
const idx = records.findIndex(r => r === deleted);
|
||||||
|
const finalIdx = idx !== -1
|
||||||
|
? idx
|
||||||
|
: records.findIndex(r =>
|
||||||
|
r.time === deleted.time &&
|
||||||
|
r.merchant === deleted.merchant &&
|
||||||
|
r.amount === deleted.amount
|
||||||
|
);
|
||||||
|
|
||||||
|
if (finalIdx !== -1) {
|
||||||
|
records.splice(finalIdx, 1);
|
||||||
|
records = [...records];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedRecord = null;
|
||||||
|
selectedRank = 0;
|
||||||
|
onDelete?.(deleted);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
|
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
|
||||||
@@ -80,6 +101,8 @@
|
|||||||
viewDescription="查看这笔支出的完整信息"
|
viewDescription="查看这笔支出的完整信息"
|
||||||
editDescription="修改这笔支出的信息"
|
editDescription="修改这笔支出的信息"
|
||||||
onUpdate={handleRecordUpdated}
|
onUpdate={handleRecordUpdated}
|
||||||
|
onDelete={handleRecordDeleted}
|
||||||
|
allowDelete={true}
|
||||||
>
|
>
|
||||||
{#snippet titleExtra({ isEditing })}
|
{#snippet titleExtra({ isEditing })}
|
||||||
{#if selectedRank <= 3 && !isEditing}
|
{#if selectedRank <= 3 && !isEditing}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import ChangelogDrawer from '$lib/components/ChangelogDrawer.svelte';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
@@ -24,6 +25,10 @@
|
|||||||
import User from '@lucide/svelte/icons/user';
|
import User from '@lucide/svelte/icons/user';
|
||||||
import Bell from '@lucide/svelte/icons/bell';
|
import Bell from '@lucide/svelte/icons/bell';
|
||||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||||
|
import Info from '@lucide/svelte/icons/info';
|
||||||
|
|
||||||
|
// 版本号(从 Vite 编译时注入)
|
||||||
|
const appVersion = __APP_VERSION__;
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +47,7 @@
|
|||||||
let checkingHealth = $state(true);
|
let checkingHealth = $state(true);
|
||||||
let isAuthenticated = $state(false);
|
let isAuthenticated = $state(false);
|
||||||
let currentUser = $state<AuthUser | null>(null);
|
let currentUser = $state<AuthUser | null>(null);
|
||||||
|
let changelogOpen = $state(false);
|
||||||
|
|
||||||
// 订阅认证状态
|
// 订阅认证状态
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -223,6 +229,18 @@
|
|||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
{/each}
|
{/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.Menu>
|
||||||
</Sidebar.GroupContent>
|
</Sidebar.GroupContent>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
@@ -231,6 +249,7 @@
|
|||||||
<!-- Footer: 用户信息 -->
|
<!-- Footer: 用户信息 -->
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
|
<!-- 用户信息 -->
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
@@ -344,4 +363,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</Sidebar.Inset>
|
</Sidebar.Inset>
|
||||||
</Sidebar.Provider>
|
</Sidebar.Provider>
|
||||||
|
|
||||||
|
<!-- Changelog 抽屉 -->
|
||||||
|
<ChangelogDrawer bind:open={changelogOpen} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -213,6 +213,8 @@
|
|||||||
selectedType = 'alipay';
|
selectedType = 'alipay';
|
||||||
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
|
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
|
||||||
selectedType = 'wechat';
|
selectedType = 'wechat';
|
||||||
|
} else if (fileName.includes('京东') || fileName.includes('jd')) {
|
||||||
|
selectedType = 'jd';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +265,7 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight">账单管理</h1>
|
<h1 class="text-2xl font-bold tracking-tight">账单管理</h1>
|
||||||
<p class="text-muted-foreground">上传并分析您的支付宝、微信账单</p>
|
<p class="text-muted-foreground">上传并分析您的支付宝、微信、京东账单</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
@@ -297,7 +299,7 @@
|
|||||||
<Card.Header class="flex flex-row items-center justify-between space-y-0">
|
<Card.Header class="flex flex-row items-center justify-between space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<Card.Title>上传账单</Card.Title>
|
<Card.Title>上传账单</Card.Title>
|
||||||
<Card.Description>支持支付宝、微信账单 CSV、XLSX 或 ZIP 文件</Card.Description>
|
<Card.Description>支持支付宝、微信、京东账单 CSV、XLSX 或 ZIP 文件</Card.Description>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onclick={() => goto('/bills?tab=manual')}>
|
<Button variant="outline" size="sm" onclick={() => goto('/bills?tab=manual')}>
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
@@ -397,6 +399,13 @@
|
|||||||
>
|
>
|
||||||
微信
|
微信
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedType === 'jd' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => selectedType = 'jd'}
|
||||||
|
>
|
||||||
|
京东
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -438,7 +447,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-muted-foreground">账单类型</span>
|
<span class="text-sm text-muted-foreground">账单类型</span>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : '微信'}
|
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : uploadResult.data?.bill_type === 'wechat' ? '微信' : '京东'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -126,6 +126,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBillDeleted(deleted: UIBill) {
|
||||||
|
const idx = records.findIndex(r =>
|
||||||
|
r.id === (deleted as unknown as { id?: string }).id ||
|
||||||
|
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||||
|
);
|
||||||
|
if (idx !== -1) {
|
||||||
|
records.splice(idx, 1);
|
||||||
|
records = [...records];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allIdx = allRecords.findIndex(r =>
|
||||||
|
r.id === (deleted as unknown as { id?: string }).id ||
|
||||||
|
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||||
|
);
|
||||||
|
if (allIdx !== -1) {
|
||||||
|
allRecords.splice(allIdx, 1);
|
||||||
|
allRecords = [...allRecords];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted.incomeExpense === '支出') {
|
||||||
|
backendTotalExpense = Math.max(0, backendTotalExpense - deleted.amount);
|
||||||
|
} else if (deleted.incomeExpense === '收入') {
|
||||||
|
backendTotalIncome = Math.max(0, backendTotalIncome - deleted.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 分类列表按数据中出现次数排序
|
// 分类列表按数据中出现次数排序
|
||||||
let sortedCategories = $derived(() => {
|
let sortedCategories = $derived(() => {
|
||||||
@@ -289,7 +315,12 @@
|
|||||||
<OverviewCards {totalStats} records={analysisRecords} />
|
<OverviewCards {totalStats} records={analysisRecords} />
|
||||||
|
|
||||||
<!-- 每日支出趋势图(按分类堆叠) - 使用全部数据 -->
|
<!-- 每日支出趋势图(按分类堆叠) - 使用全部数据 -->
|
||||||
<DailyTrendChart records={allAnalysisRecords} categories={sortedCategories()} onUpdate={handleBillUpdated} />
|
<DailyTrendChart
|
||||||
|
records={allAnalysisRecords}
|
||||||
|
categories={sortedCategories()}
|
||||||
|
onUpdate={handleBillUpdated}
|
||||||
|
onDelete={handleBillDeleted}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-2">
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<!-- 分类支出排行 -->
|
<!-- 分类支出排行 -->
|
||||||
@@ -307,7 +338,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top 10 支出 -->
|
<!-- Top 10 支出 -->
|
||||||
<TopExpenses records={topExpenses} categories={sortedCategories()} onUpdate={handleBillUpdated} />
|
<TopExpenses
|
||||||
|
records={topExpenses}
|
||||||
|
categories={sortedCategories()}
|
||||||
|
onUpdate={handleBillUpdated}
|
||||||
|
onDelete={handleBillDeleted}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
|
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
|
|||||||
@@ -380,13 +380,14 @@
|
|||||||
<Label class="text-xs">来源</Label>
|
<Label class="text-xs">来源</Label>
|
||||||
<Select.Root type="single" value={filterBillType || undefined} onValueChange={handleBillTypeChange}>
|
<Select.Root type="single" value={filterBillType || undefined} onValueChange={handleBillTypeChange}>
|
||||||
<Select.Trigger class="h-9 w-full">
|
<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.Trigger>
|
||||||
<Select.Portal>
|
<Select.Portal>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
<Select.Item value="">全部</Select.Item>
|
<Select.Item value="">全部</Select.Item>
|
||||||
<Select.Item value="alipay">支付宝</Select.Item>
|
<Select.Item value="alipay">支付宝</Select.Item>
|
||||||
<Select.Item value="wechat">微信</Select.Item>
|
<Select.Item value="wechat">微信</Select.Item>
|
||||||
|
<Select.Item value="jd">京东</Select.Item>
|
||||||
<Select.Item value="manual">手动</Select.Item>
|
<Select.Item value="manual">手动</Select.Item>
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
@@ -438,8 +439,8 @@
|
|||||||
{formatDateTime(record.time)}
|
{formatDateTime(record.time)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="hidden xl:table-cell">
|
<Table.Cell class="hidden xl:table-cell">
|
||||||
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : 'secondary')}>
|
<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 === 'manual' ? '手动输入' : (record.bill_type === 'alipay' ? '支付宝' : (record.bill_type === 'jd' ? '京东' : '微信'))}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
|
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
|
||||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||||
|
import { updateBill } from '$lib/api';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
import PartyPopper from '@lucide/svelte/icons/party-popper';
|
import PartyPopper from '@lucide/svelte/icons/party-popper';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import Check from '@lucide/svelte/icons/check';
|
||||||
|
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
@@ -20,6 +22,9 @@
|
|||||||
let allBills = $state<CleanedBill[]>([]);
|
let allBills = $state<CleanedBill[]>([]);
|
||||||
let filterLevel = $state<'all' | 'HIGH' | 'LOW'>('all');
|
let filterLevel = $state<'all' | 'HIGH' | 'LOW'>('all');
|
||||||
|
|
||||||
|
// 快捷确认按钮的加载状态 (记录ID -> 是否在加载)
|
||||||
|
let confirmingBills = $state<Map<string, boolean>>(new Map());
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadReviewData();
|
loadReviewData();
|
||||||
});
|
});
|
||||||
@@ -88,6 +93,48 @@
|
|||||||
drawerOpen = true;
|
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) {
|
function handleBillUpdate(updated: UIBill, original: UIBill) {
|
||||||
// 更新后 review_level 已被清除,从列表中移除
|
// 更新后 review_level 已被清除,从列表中移除
|
||||||
@@ -254,6 +301,7 @@
|
|||||||
<Table.Head>收/支</Table.Head>
|
<Table.Head>收/支</Table.Head>
|
||||||
<Table.Head class="text-right">金额</Table.Head>
|
<Table.Head class="text-right">金额</Table.Head>
|
||||||
<Table.Head class="w-[80px]">优先级</Table.Head>
|
<Table.Head class="w-[80px]">优先级</Table.Head>
|
||||||
|
<Table.Head class="w-[100px] text-center">操作</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -287,6 +335,23 @@
|
|||||||
{record.review_level}
|
{record.review_level}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import { defineConfig } from 'vitest/config';
|
|||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import tailwindcss from '@tailwindcss/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({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), tailwindcss()],
|
plugins: [sveltekit(), tailwindcss()],
|
||||||
|
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version)
|
||||||
|
},
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
Reference in New Issue
Block a user