Compare commits
10 Commits
a1eebd0b3f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 642ea2d3ef | |||
|
|
a5f1a370c7 | ||
|
|
b7399d185f | ||
|
|
5537e1234d | ||
|
|
f6437b2ada | ||
|
|
cc0623c15a | ||
|
|
cb4273fad0 | ||
|
|
99ec5ea0a4 | ||
|
|
89e1e74b76 | ||
|
|
ed0a44851d |
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
|
||||||
158
AGENTS.md
158
AGENTS.md
@@ -3,139 +3,125 @@
|
|||||||
Guidelines for AI coding agents working on BillAI - a microservices bill analysis system.
|
Guidelines for AI coding agents working on BillAI - a microservices bill analysis system.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript
|
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend Proxy & UI)
|
||||||
- `server/` - Go 1.21 + Gin + MongoDB
|
- `server/` - Go 1.21 + Gin + MongoDB (Main API & Data Storage)
|
||||||
- `analyzer/` - Python 3.12 + FastAPI
|
- `analyzer/` - Python 3.12 + FastAPI (Data Cleaning & Analysis Service)
|
||||||
|
|
||||||
## Build/Lint/Test Commands
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
### Frontend (web/)
|
### Frontend (web/)
|
||||||
|
**Working Directory:** `/Users/clz/Projects/BillAI/web`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start dev server
|
npm run dev # Start dev server
|
||||||
npm run build # Production build
|
npm run build # Production build
|
||||||
npm run check # TypeScript check
|
npm run check # TypeScript check (svelte-check)
|
||||||
npm run lint # Prettier + ESLint
|
npm run lint # Prettier + ESLint
|
||||||
npm run format # Format code
|
npm run format # Format code (Prettier)
|
||||||
npm run test # Run all tests
|
npm run test:unit # Run all unit tests (Vitest)
|
||||||
npx vitest run src/routes/+page.spec.ts # Single test file
|
npx vitest run src/routes/+page.spec.ts # Run single test file
|
||||||
npx vitest run -t "test name" # Test by name
|
npx vitest run -t "test name" # Run test by name pattern
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (server/)
|
### Backend (server/)
|
||||||
|
**Working Directory:** `/Users/clz/Projects/BillAI/server`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run . # Start server
|
go run . # Start server
|
||||||
go build . # Build binary
|
go build -o server . # Build binary
|
||||||
go mod tidy # Clean dependencies
|
go mod tidy # Clean dependencies
|
||||||
go test ./... # All tests
|
go test ./... # Run all tests
|
||||||
go test ./handler/... # Package tests
|
go test ./handler/... # Run package tests
|
||||||
go test -run TestName # Single test
|
go test -run TestName # Run single test function
|
||||||
|
go test -v ./handler/... # Run tests with verbose output
|
||||||
```
|
```
|
||||||
|
|
||||||
### Analyzer (analyzer/)
|
### Analyzer (analyzer/)
|
||||||
|
**Working Directory:** `/Users/clz/Projects/BillAI/analyzer`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python server.py # Start FastAPI server
|
python server.py # Start FastAPI server directly
|
||||||
pytest # All tests
|
uvicorn server:app --reload # Start with hot reload
|
||||||
pytest test_file.py # Single file
|
pytest # Run all tests
|
||||||
pytest -k "test_name" # Test by pattern
|
pytest test_file.py # Run single test file
|
||||||
|
pytest -k "test_name" # Run test by name pattern
|
||||||
|
pip install -r requirements.txt # Install dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
**Working Directory:** `/Users/clz/Projects/BillAI`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d --build # Start/rebuild all
|
docker-compose up -d --build # Start/rebuild all services
|
||||||
docker-compose logs -f server # Follow service logs
|
docker-compose logs -f server # Follow service logs
|
||||||
|
docker-compose down # Stop all services
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
### TypeScript/Svelte
|
### General
|
||||||
**Prettier config:** Tabs, single quotes, no trailing commas, width 100
|
- **Comments:** Existing comments often use Chinese for business logic explanations. Maintain this style where appropriate, but English is also acceptable for technical explanations.
|
||||||
|
- **Conventions:** Follow existing patterns strictly. Do not introduce new frameworks or libraries without checking `package.json`/`go.mod`/`requirements.txt`.
|
||||||
|
|
||||||
**Imports:**
|
### TypeScript/Svelte (web/)
|
||||||
|
- **Formatting:** Prettier (Tabs, single quotes, no trailing commas, printWidth 100).
|
||||||
|
- **Naming:** `PascalCase` for types/components/interfaces, `camelCase` for variables/functions.
|
||||||
|
- **Imports:** Use `$lib` alias for internal imports.
|
||||||
```typescript
|
```typescript
|
||||||
import { browser } from '$app/environment'; // SvelteKit
|
import { browser } from '$app/environment';
|
||||||
import { auth } from '$lib/stores/auth'; // Internal
|
import { auth } from '$lib/stores/auth';
|
||||||
import type { UIBill } from '$lib/models/bill';
|
import type { UIBill } from '$lib/models/bill';
|
||||||
```
|
```
|
||||||
|
- **Types:** Define interfaces for data models. Use `export interface`.
|
||||||
|
- **Error Handling:** Check `response.ok`. Throw `Error` with status for UI to catch.
|
||||||
|
|
||||||
**Types:**
|
### Go Backend (server/)
|
||||||
```typescript
|
- **Structure:** `handler` (HTTP) → `service` (Logic) → `repository` (DB) → `model` (Structs).
|
||||||
export interface UploadResponse {
|
- **Tags:** Use `json` (snake_case) and `form` tags. Use `omitempty` for optional fields.
|
||||||
result: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: UploadData;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Naming:** PascalCase (types, components), camelCase (functions, variables)
|
|
||||||
|
|
||||||
**Error handling:**
|
|
||||||
```typescript
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
// Handle 401 -> logout redirect
|
|
||||||
```
|
|
||||||
|
|
||||||
### Go Backend
|
|
||||||
**Structure:** `handler/` → `service/` → `repository/` → MongoDB
|
|
||||||
|
|
||||||
**JSON tags:** snake_case, omitempty for optional fields
|
|
||||||
```go
|
```go
|
||||||
type UpdateBillRequest struct {
|
type UpdateBillRequest struct {
|
||||||
Category *string `json:"category,omitempty"`
|
Category *string `json:"category,omitempty" form:"category"`
|
||||||
Amount *float64 `json:"amount,omitempty"`
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- **Error Handling:** Return `500` for DB errors, `400` for bad requests. Wrap errors with context.
|
||||||
**Response format:**
|
|
||||||
```go
|
```go
|
||||||
type Response struct {
|
if err != nil {
|
||||||
Result bool `json:"result"`
|
c.JSON(http.StatusInternalServerError, Response{Result: false, Message: err.Error()})
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Data interface{} `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error handling:**
|
|
||||||
```go
|
|
||||||
if err == repository.ErrNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, Response{Result: false, Message: "not found"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python Analyzer
|
### Python Analyzer (analyzer/)
|
||||||
**Style:** PEP 8, type hints, Pydantic models
|
- **Style:** PEP 8. Use `snake_case` for variables/functions.
|
||||||
|
- **Type Hints:** Mandatory for function arguments and return types.
|
||||||
|
- **Models:** Use `pydantic.BaseModel` for API schemas.
|
||||||
```python
|
```python
|
||||||
def do_clean(
|
class CleanRequest(BaseModel):
|
||||||
input_path: str,
|
input_path: str
|
||||||
output_path: str,
|
bill_type: Optional[str] = "auto"
|
||||||
bill_type: str = "auto"
|
|
||||||
) -> tuple[bool, str, str]:
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error handling:**
|
|
||||||
```python
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=400, detail=message)
|
|
||||||
```
|
```
|
||||||
|
- **Docstrings:** Use triple quotes. Chinese descriptions are common for API docs.
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
**API Flow:** Frontend (SvelteKit proxy) → Go API → MongoDB + Python analyzer
|
- **API Flow:**
|
||||||
|
- Frontend talks to `server` (Go) via `/api` proxy.
|
||||||
|
- `server` handles auth, DB operations, and delegates complex file processing to `analyzer` (Python).
|
||||||
|
- `analyzer` cleanses CSV/Excel files and returns structured JSON/CSV to `server`.
|
||||||
|
|
||||||
**Auth:** JWT tokens, Bearer header, 401 → logout redirect
|
- **Authentication:**
|
||||||
|
- JWT based. Token stored in frontend.
|
||||||
|
- Header: `Authorization: Bearer <token>`.
|
||||||
|
- Backend middleware checks token. 401 triggers logout/redirect.
|
||||||
|
|
||||||
**File Processing:** ZIP → extract → convert (GBK→UTF-8, xlsx→csv) → clean → import
|
- **File Processing:**
|
||||||
|
- Flow: Upload (ZIP/XLSX) -> Extract/Convert (to UTF-8 CSV) -> Clean (normalize columns) -> Import to DB.
|
||||||
**Testing:** Vitest + Playwright for frontend, Go test for backend
|
- `analyzer` uses `openpyxl` for Excel and regex for cleaning text.
|
||||||
|
|
||||||
## Important Files
|
## Important Files
|
||||||
- `web/src/lib/api.ts` - API client
|
- `web/src/lib/api.ts` - Centralized API client methods.
|
||||||
- `web/src/lib/models/` - UI data models
|
- `web/src/lib/models/*.ts` - Frontend data models (should match backend JSON).
|
||||||
- `server/handler/` - HTTP handlers
|
- `server/handler/*.go` - HTTP endpoint definitions.
|
||||||
- `server/service/` - Business logic
|
- `server/repository/mongo.go` - MongoDB connection and queries.
|
||||||
- `server/model/` - Go data structures
|
- `analyzer/server.py` - FastAPI entry point and routing.
|
||||||
- `analyzer/cleaners/` - Bill processing
|
- `analyzer/cleaners/*.py` - Specific logic for Alipay/Wechat/JD bills.
|
||||||
- `mock_data/*.zip` - Test data (password: 123456)
|
|
||||||
|
|||||||
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
|
||||||
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
|
||||||
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
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
5
web/.dockerignore
Normal file
5
web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 数组
|
||||||
@@ -47,6 +48,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);
|
||||||
let selectedDate = $state<Date | null>(null);
|
let selectedDate = $state<Date | null>(null);
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -127,6 +127,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(() => {
|
||||||
const categoryCounts = new Map<string, number>();
|
const categoryCounts = new Map<string, number>();
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user