Compare commits
30 Commits
f537b53ebd
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a2de8c5078 | |||
| e2e1beb6f7 | |||
| bb717faac3 | |||
| 31932502d0 | |||
| ee163e123d | |||
| c4d8c2e105 | |||
| 7caac4d93c | |||
| ac79b4f2ea | |||
| 21d73c212a | |||
| 02de11caac | |||
| d813fe4307 | |||
| 50ae47588a | |||
| 5f10173ffb | |||
| 7022e46793 | |||
| f68cd2f6ea | |||
| aa4f1615ce | |||
| 642ea2d3ef | |||
|
|
a5f1a370c7 | ||
|
|
b7399d185f | ||
|
|
5537e1234d | ||
|
|
f6437b2ada | ||
|
|
cc0623c15a | ||
|
|
cb4273fad0 | ||
|
|
99ec5ea0a4 | ||
|
|
89e1e74b76 | ||
|
|
ed0a44851d | ||
|
|
a1eebd0b3f | ||
|
|
ef34a1bb5d | ||
|
|
ab9aab7beb | ||
|
|
61d26fc971 |
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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ analyzer/venv/
|
|||||||
|
|
||||||
# Go
|
# Go
|
||||||
server/billai-server
|
server/billai-server
|
||||||
|
server/server
|
||||||
.exe
|
.exe
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
|
|||||||
221
AGENTS.md
221
AGENTS.md
@@ -2,140 +2,153 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
**Version:** See `CHANGELOG.md` for current version. Latest tag usually matches.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript
|
|
||||||
- `server/` - Go 1.21 + Gin + MongoDB
|
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend, port 3000)
|
||||||
- `analyzer/` - Python 3.12 + FastAPI
|
- `server/` - Go 1.24 + Gin + MongoDB (API, port 8080)
|
||||||
|
- `analyzer/` - Python 3.12 + FastAPI (Data cleaning, port 8001)
|
||||||
|
|
||||||
|
**Proxy Caveat:** SvelteKit proxies `/api/*` to Go via `web/src/routes/api/[...path]/+server.ts`, but **only GET and POST are forwarded**. The Go backend intentionally uses `POST` for mutations (update, delete, manual create) to work around this. If you add PUT/PATCH/DELETE endpoints, you must also add them to the proxy.
|
||||||
|
|
||||||
## Build/Lint/Test Commands
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
### Frontend (web/)
|
### Frontend (`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 + Svelte check
|
||||||
npm run lint # Prettier + ESLint
|
npm run lint # Prettier --check + ESLint
|
||||||
npm run format # Format code
|
npm run format # Prettier --write
|
||||||
npm run test # Run all tests
|
npm run test # vitest --run (CI mode)
|
||||||
npx vitest run src/routes/+page.spec.ts # Single test file
|
npx vitest run src/xxx.spec.ts # Run single test file
|
||||||
npx vitest run -t "test name" # Test by name
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (server/)
|
**Note:** `web/` has a `yarn.lock` but scripts in `package.json` use `npm run`.
|
||||||
|
|
||||||
|
### Backend (`server/`)
|
||||||
```bash
|
```bash
|
||||||
go run . # Start server
|
go run . # Start server (reads server/config.yaml)
|
||||||
go build . # Build binary
|
go build -o server .
|
||||||
go mod tidy # Clean dependencies
|
go test ./... # Run all tests
|
||||||
go test ./... # All tests
|
go test -run TestName ./... # Run single test
|
||||||
go test ./handler/... # Package tests
|
go test -v ./handler/... # Verbose output
|
||||||
go test -run TestName # Single test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Analyzer (analyzer/)
|
### Analyzer (`analyzer/`)
|
||||||
```bash
|
```bash
|
||||||
python server.py # Start FastAPI server
|
python server.py # Start FastAPI (has `if __name__ == "__main__"`)
|
||||||
pytest # All tests
|
uvicorn server:app --reload # Requires cwd == analyzer/
|
||||||
pytest test_file.py # Single file
|
pytest # Run all tests
|
||||||
pytest -k "test_name" # Test by pattern
|
pytest test_jd_cleaner.py # Single test file
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d --build # Start/rebuild all
|
docker compose up -d --build --remove-orphans # Start/rebuild all
|
||||||
docker-compose logs -f server # Follow service logs
|
docker compose logs -f server # Follow logs
|
||||||
|
docker compose down # Stop services
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style
|
## Code Style & Conventions
|
||||||
|
|
||||||
### TypeScript/Svelte
|
### TypeScript/Svelte (`web/`)
|
||||||
**Prettier config:** Tabs, single quotes, no trailing commas, width 100
|
- **Formatting:** Prettier (tabs, single quotes, `trailingComma: none`, printWidth 100)
|
||||||
|
- **Imports:** Use `$lib` alias. No relative imports for lib modules.
|
||||||
|
- **Svelte 5:** Runes (`$state`, `$derived`, `$effect`, `$props`). Events: `onclick={fn}`.
|
||||||
|
- **Types:** `export interface` for models. Frontend uses `camelCase`, API uses `snake_case`. Converters live in `$lib/models/bill.ts`.
|
||||||
|
- **Auth:** Token stored in `localStorage` key `auth`. Always use `apiFetch()` from `$lib/api.ts` for authenticated requests.
|
||||||
|
|
||||||
**Imports:**
|
### Go (`server/`)
|
||||||
```typescript
|
- **Module:** `billai-server` (import path). Use this in `go test` / `go build` when outside the directory.
|
||||||
import { browser } from '$app/environment'; // SvelteKit
|
- **Layer:** `handler` → `service` → `adapter`/`repository` → `model`. No business logic in handlers.
|
||||||
import { auth } from '$lib/stores/auth'; // Internal
|
- **Struct tags:** JSON `snake_case`, `omitempty` for optional. Pointer types for optional patch fields. Sensitive fields: `json:"-"`.
|
||||||
import type { UIBill } from '$lib/models/bill';
|
- **Response shapes:**
|
||||||
|
- Business APIs: `result bool`, `message string`, `data *T`
|
||||||
|
- Auth APIs: `success bool`, `error string`, `data *T` (and `code` for error types)
|
||||||
|
- **Time:** Custom `LocalTime` type serializes as `"2006-01-02 15:04:05"`.
|
||||||
|
- **Soft delete:** Never hard-delete. All queries filter `is_deleted: false`.
|
||||||
|
|
||||||
|
### Python (`analyzer/`)
|
||||||
|
- **Style:** PEP 8. `snake_case` vars, `UPPER_CASE` constants. Prefix private globals with `_`.
|
||||||
|
- **Type hints:** Mandatory. Prefer `str | None` or `Optional[str]`.
|
||||||
|
- **Models:** `pydantic.BaseModel` for API schemas.
|
||||||
|
- **Cleaners:** Extend `BaseCleaner(ABC)` from `cleaners/base.py`. Category rules in `config/category.yaml`.
|
||||||
|
|
||||||
|
## Key Patterns & Quirks
|
||||||
|
|
||||||
|
### API Flow
|
||||||
|
```
|
||||||
|
Browser → SvelteKit proxy → Go (Gin) → handler → service → adapter → Python FastAPI
|
||||||
|
└→ repository → MongoDB
|
||||||
```
|
```
|
||||||
|
|
||||||
**Types:**
|
### Authentication
|
||||||
```typescript
|
- JWT (HS256). Header: `Authorization: Bearer <token>`.
|
||||||
export interface UploadResponse {
|
- `middleware.AuthRequired()` guards authed routes. Public routes: `/api/auth/*`, `/api/changelog`, `/health`.
|
||||||
result: boolean;
|
- Frontend `apiFetch()` intercepts 401 → `auth.logout()` + redirect `/login`.
|
||||||
message: string;
|
|
||||||
data?: UploadData;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Naming:** PascalCase (types, components), camelCase (functions, variables)
|
### File Processing Pipeline
|
||||||
|
Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type (alipay/wechat/jd) → Deduplicate against MongoDB → Clean via Python → Save cleaned data.
|
||||||
|
|
||||||
**Error handling:**
|
### Cross-Batch Refund Reconciliation
|
||||||
```typescript
|
When the original purchase and its refund are in different upload batches, within-batch logic can't match them. Two reconciliation paths handle this:
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
// Handle 401 -> logout redirect
|
|
||||||
```
|
|
||||||
|
|
||||||
### Go Backend
|
**Alipay** (`handler/upload.go` step 14): After cleaning, `cleaner.unresolved_refunds` (refund rows that found no matching expense in the same file) is returned by FastAPI and iterated in Go. `ReconcileRefund()` looks up the original expense by `transaction_id` or `merchant_order_no` and either soft-deletes (full refund) or deducts `amount` (partial). Alipay refund rows have their own distinct `transaction_id` (suffixed `_<refund_id>`), so they pass raw dedup and are naturally idempotent on re-upload.
|
||||||
**Structure:** `handler/` → `service/` → `repository/` → MongoDB
|
|
||||||
|
|
||||||
**JSON tags:** snake_case, omitempty for optional fields
|
**WeChat** (`handler/upload.go` step 15): WeChat re-exports the *same row* (same `transaction_id`) with an updated `当前状态` field (`已全额退款` or `已退款(¥X)`). `DeduplicateRawFile` detects these duplicate rows, extracts the refund info into `DeduplicateResult.WechatRefundUpdates`, and `ReconcileWechatRefund()` applies the update. WeChat's `¥X` is the **cumulative** total refunded, so `CleanedBill.original_amount` (set at first save) is used to compute `remaining = original_amount - X`.
|
||||||
```go
|
|
||||||
type UpdateBillRequest struct {
|
|
||||||
Category *string `json:"category,omitempty"`
|
|
||||||
Amount *float64 `json:"amount,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response format:**
|
### Adapter (Go ↔ Python)
|
||||||
```go
|
`adapter.Cleaner` interface. Two modes:
|
||||||
type Response struct {
|
- `http` (default): calls FastAPI at `ANALYZER_URL`
|
||||||
Result bool `json:"result"`
|
- `subprocess`: spawns `python analyzer/clean_bill.py`
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Data interface{} `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error handling:**
|
Set via `ANALYZER_MODE` env var or `server/config.yaml` `analyzer.mode`.
|
||||||
```go
|
|
||||||
if err == repository.ErrNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, Response{Result: false, Message: "not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python Analyzer
|
### Config Precedence
|
||||||
**Style:** PEP 8, type hints, Pydantic models
|
Go backend reads `server/config.yaml`, but Docker compose sets env vars (`ANALYZER_URL`, `MONGO_URI`, `JWT_SECRET`, etc.) that override it.
|
||||||
|
|
||||||
```python
|
### SvelteKit Config Notes
|
||||||
def do_clean(
|
- `svelte.config.js` uses `adapter-node` for Docker SSR.
|
||||||
input_path: str,
|
- `csrf.trustedOrigins: ['*']` disables CSRF checks.
|
||||||
output_path: str,
|
- `onwarn` ignores all `a11y_*` warnings (chart components).
|
||||||
bill_type: str = "auto"
|
|
||||||
) -> tuple[bool, str, str]:
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error handling:**
|
### Deployment
|
||||||
```python
|
- Gitea Actions self-hosted runner (`.gitea/workflows/deploy.yaml`), not GitHub.
|
||||||
if not success:
|
- `deploy.sh` is the manual deployment script (same logic as CI).
|
||||||
raise HTTPException(status_code=400, detail=message)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Patterns
|
### Test Coverage
|
||||||
|
Sparse. Existing tests:
|
||||||
**API Flow:** Frontend (SvelteKit proxy) → Go API → MongoDB + Python analyzer
|
- `web/src/demo.spec.ts` / `page.svelte.spec.ts`
|
||||||
|
- `server/service/changelog_test.go`
|
||||||
**Auth:** JWT tokens, Bearer header, 401 → logout redirect
|
- `analyzer/test_jd_cleaner.py`
|
||||||
|
|
||||||
**File Processing:** ZIP → extract → convert (GBK→UTF-8, xlsx→csv) → clean → import
|
|
||||||
|
|
||||||
**Testing:** Vitest + Playwright for frontend, Go test for backend
|
|
||||||
|
|
||||||
## Important Files
|
## Important Files
|
||||||
- `web/src/lib/api.ts` - API client
|
|
||||||
- `web/src/lib/models/` - UI data models
|
| File | Role |
|
||||||
- `server/handler/` - HTTP handlers
|
|---|---|
|
||||||
- `server/service/` - Business logic
|
| `web/src/lib/api.ts` | Central API client, auth injection, all API functions |
|
||||||
- `server/model/` - Go data structures
|
| `web/src/lib/stores/auth.ts` | Auth state, JWT handling, localStorage key `auth` |
|
||||||
- `analyzer/cleaners/` - Bill processing
|
| `web/src/lib/models/bill.ts` | UIBill model + snake_case ↔ camelCase converters |
|
||||||
- `mock_data/*.zip` - Test data (password: 123456)
|
| `web/src/routes/api/[...path]/+server.ts` | SvelteKit → Go proxy (GET/POST only) |
|
||||||
|
| `server/main.go` | Entry point, wires adapter + repository + router |
|
||||||
|
| `server/config.yaml` | Go backend config (Mongo, auth, paths, analyzer mode) |
|
||||||
|
| `server/router/router.go` | Route table, auth group definitions |
|
||||||
|
| `server/handler/upload.go` | Full upload pipeline handler |
|
||||||
|
| `server/handler/bills.go` | List/filter/update/delete bills |
|
||||||
|
| `server/model/bill.go` | Bill models, LocalTime type, BSON/JSON marshaling |
|
||||||
|
| `server/adapter/adapter.go` | Cleaner interface definition |
|
||||||
|
| `server/repository/mongo/repository.go` | MongoDB implementation, soft-delete queries |
|
||||||
|
| `analyzer/server.py` | FastAPI entry, bill detection/clean endpoints |
|
||||||
|
| `analyzer/cleaners/base.py` | BaseCleaner ABC |
|
||||||
|
| `analyzer/category.py` | Category inference engine |
|
||||||
|
| `docker-compose.yaml` | Full stack orchestration |
|
||||||
|
|
||||||
|
## Agent Guidelines
|
||||||
|
|
||||||
|
- **Before coding:** Search codebase to understand existing patterns and dependencies.
|
||||||
|
- **Dependencies:** Check `package.json`/`go.mod`/`requirements.txt` before adding new packages.
|
||||||
|
- **Tests:** Run the relevant test suite before committing. If no tests exist for your change, verify manually.
|
||||||
|
- **Git commits:** Provide clear messages explaining the "why" of changes.
|
||||||
|
- **File references:** Use relative `file_path:line_number` format (e.g., `server/handler/changelog.go:12`) when mentioning code locations.
|
||||||
|
|||||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -5,6 +5,63 @@
|
|||||||
格式基于 [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/)。
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **微信跨批次退款核销** - 重复上传微信账单时,自动识别"当前状态"字段中的退款信息并核销原始支出记录
|
||||||
|
- 支持「已全额退款」:原支出记录软删除
|
||||||
|
- 支持「已退款(¥X)」:累计退款金额从原始金额(`original_amount`)中扣减,剩余金额更新到记录
|
||||||
|
- 新增 `CleanedBill.original_amount` 字段,存储入库时的原始金额,保证多次部分退款累计计算正确
|
||||||
|
- `ReconcileWechatRefund` 接口和 MongoDB 实现
|
||||||
|
- **支付宝跨批次退款核销** - 上传含退款行的支付宝账单时,若原消费记录来自更早的批次,自动在数据库中核销
|
||||||
|
- 退款行的「交易订单号」带后缀(`原订单号_退款编号`),天然幂等:重复上传时被原始数据去重拦截,不会重复核销
|
||||||
|
- 全额退款软删除,部分退款扣减金额并追加备注
|
||||||
|
- `ReconcileRefund` 接口和 MongoDB 实现
|
||||||
|
|
||||||
|
### 技术改进
|
||||||
|
- `server/service/bill.go`:`DeduplicateRawFile` 在微信账单去重阶段检测已退款状态行,收集 `WechatRefundUpdates` 供后续核销
|
||||||
|
- `server/service/bill.go`:`saveCleanedBillsFromCSV` / `saveCleanedBillsFromJSON` 入库时同步写入 `original_amount`
|
||||||
|
- `analyzer/cleaners/alipay.py`:`_process_expenses` 收集同批次内未匹配的退款(`unresolved_refunds`),通过 FastAPI 响应透传至 Go
|
||||||
|
- `analyzer/cleaners/base.py`:`BaseCleaner` 基类新增 `unresolved_refunds` 属性
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-03-23
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **账单导出 Excel 功能** - 支持将筛选后的账单导出为 Excel 文件
|
||||||
|
- 后端新增 `/api/bills/export` 接口,根据当前筛选条件导出全部记录(无分页限制)
|
||||||
|
- 使用 excelize 库生成 xlsx 格式文件
|
||||||
|
- 前端账单管理页面标题栏添加"导出 Excel"按钮
|
||||||
|
- 支持日期范围、分类、来源、收支类型等筛选条件
|
||||||
|
- Excel 包含:时间、来源、分类、交易对方、商品说明、收/支、金额、支付方式、状态、备注
|
||||||
|
- **酒店旅游分类** - 新增「酒店旅游」支出分类
|
||||||
|
- 涵盖关键词:酒店、宾馆、民宿、客栈、携程、飞猪、去哪儿、同程、旅游、旅行、景区、门票、度假等
|
||||||
|
- 相关关键词从「文化休闲」和「交通出行」中分离,避免分类冲突
|
||||||
|
- **动态版本日志系统** - 将版本更新日志从前端硬编码改为动态获取
|
||||||
|
- 后端新增 `/api/changelog` 公开接口,实时解析 CHANGELOG.md
|
||||||
|
- 前端ChangelogDrawer组件改为异步加载日志,支持加载态和错误处理
|
||||||
|
- 新增 Markdown 解析器,自动提取版本、日期和分类变更内容
|
||||||
|
|
||||||
|
### 技术改进
|
||||||
|
- Go 版本升级到 1.24(支持 excelize 依赖)
|
||||||
|
- 新增 `server/handler/export.go` 导出处理器
|
||||||
|
- 新增 `web/src/lib/api.ts` 中的 `exportBills()` 函数
|
||||||
|
- 新增 `server/handler/changelog.go` 和 `server/service/changelog.go` 日志解析模块
|
||||||
|
- 导出 `apiFetch` 函数供公开 API 调用
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- **各页面账单分类不一致** - 账单列表页和复核页改从 `$lib/data/categories` 统一导入分类列表
|
||||||
|
- 删除两处本地重复硬编码的旧版 13 项分类
|
||||||
|
- `BillDetailDrawer` 的 `categories` prop 类型改为 `readonly string[]`
|
||||||
|
- **前端版本日志显示** - 移除硬编码的 14 个版本数据,改为从 API 动态加载
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-01-26
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
- **版本号显示优化** - 侧边栏版本号按钮样式改进
|
||||||
|
- 移至次级导航区域,与其他菜单项样式一致
|
||||||
|
- 更新日志改用 Sheet 组件(右侧滑出),替代底部 Drawer
|
||||||
|
- 统一暗色主题下的视觉效果
|
||||||
|
|
||||||
## [1.3.0] - 2026-01-26
|
## [1.3.0] - 2026-01-26
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
@AGENTS.md
|
||||||
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
|
||||||
@@ -112,8 +112,15 @@ class AlipayCleaner(BaseCleaner):
|
|||||||
|
|
||||||
if key:
|
if key:
|
||||||
if key not in order_refunds:
|
if key not in order_refunds:
|
||||||
order_refunds[key] = Decimal("0")
|
order_refunds[key] = {
|
||||||
order_refunds[key] += refund_amount
|
"amount": Decimal("0"),
|
||||||
|
"merchant_order_no": refund_merchant_no,
|
||||||
|
"refund_order_no": refund_order_no,
|
||||||
|
"time": row[0],
|
||||||
|
"merchant": row[2],
|
||||||
|
"description": row[4] if len(row) > 4 else "",
|
||||||
|
}
|
||||||
|
order_refunds[key]["amount"] += refund_amount
|
||||||
print(f" 退款记录: {row[0]} | {row[2]} | {refund_amount}元")
|
print(f" 退款记录: {row[0]} | {row[2]} | {refund_amount}元")
|
||||||
|
|
||||||
return order_refunds
|
return order_refunds
|
||||||
@@ -121,6 +128,7 @@ class AlipayCleaner(BaseCleaner):
|
|||||||
def _process_expenses(self, expense_rows: list, order_refunds: dict) -> list:
|
def _process_expenses(self, expense_rows: list, order_refunds: dict) -> list:
|
||||||
"""处理支出记录"""
|
"""处理支出记录"""
|
||||||
final_rows = []
|
final_rows = []
|
||||||
|
matched_keys = set()
|
||||||
|
|
||||||
for row in expense_rows:
|
for row in expense_rows:
|
||||||
if len(row) >= 12:
|
if len(row) >= 12:
|
||||||
@@ -132,13 +140,14 @@ class AlipayCleaner(BaseCleaner):
|
|||||||
refund_amount = Decimal("0")
|
refund_amount = Decimal("0")
|
||||||
matched_key = None
|
matched_key = None
|
||||||
|
|
||||||
for key, amount in order_refunds.items():
|
for key, refund in order_refunds.items():
|
||||||
if key and (order_no == key or merchant_no == key or order_no.startswith(key)):
|
if key and (order_no == key or merchant_no == key or order_no.startswith(key)):
|
||||||
refund_amount = amount
|
refund_amount = refund["amount"]
|
||||||
matched_key = key
|
matched_key = key
|
||||||
break
|
break
|
||||||
|
|
||||||
if matched_key:
|
if matched_key:
|
||||||
|
matched_keys.add(matched_key)
|
||||||
if refund_amount >= expense_amount:
|
if refund_amount >= expense_amount:
|
||||||
# 全额退款,删除
|
# 全额退款,删除
|
||||||
self.stats["fully_refunded"] += 1
|
self.stats["fully_refunded"] += 1
|
||||||
@@ -164,6 +173,21 @@ class AlipayCleaner(BaseCleaner):
|
|||||||
else:
|
else:
|
||||||
final_rows.append(row)
|
final_rows.append(row)
|
||||||
|
|
||||||
|
# 本批次内未匹配到对应支出的退款,交由调用方做跨批次核销
|
||||||
|
self.unresolved_refunds = [
|
||||||
|
{
|
||||||
|
"order_no": key,
|
||||||
|
"merchant_order_no": refund["merchant_order_no"],
|
||||||
|
"refund_order_no": refund["refund_order_no"],
|
||||||
|
"amount": float(format_amount(refund["amount"])),
|
||||||
|
"time": refund["time"],
|
||||||
|
"merchant": refund["merchant"],
|
||||||
|
"description": refund["description"],
|
||||||
|
}
|
||||||
|
for key, refund in order_refunds.items()
|
||||||
|
if key not in matched_keys
|
||||||
|
]
|
||||||
|
|
||||||
return final_rows
|
return final_rows
|
||||||
|
|
||||||
def _is_platform_merchant(self, merchant: str) -> bool:
|
def _is_platform_merchant(self, merchant: str) -> bool:
|
||||||
|
|||||||
@@ -221,6 +221,9 @@ class BaseCleaner(ABC):
|
|||||||
"final_count": 0,
|
"final_count": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 本次清理中未能在同批次内匹配到对应支出的退款(跨批次核销用)
|
||||||
|
self.unresolved_refunds: list[dict] = []
|
||||||
|
|
||||||
def set_date_range(self, start_date: date | None, end_date: date | None):
|
def set_date_range(self, start_date: date | None, end_date: date | None):
|
||||||
"""设置日期筛选范围"""
|
"""设置日期筛选范围"""
|
||||||
self.start_date = start_date
|
self.start_date = start_date
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ class WechatCleaner(BaseCleaner):
|
|||||||
# 第三步:处理退款(包括转账退款)
|
# 第三步:处理退款(包括转账退款)
|
||||||
final_expense_rows, income_rows = self._process_refunds(expense_rows, income_rows)
|
final_expense_rows, income_rows = self._process_refunds(expense_rows, income_rows)
|
||||||
|
|
||||||
|
# 收集跨批次未匹配的 -退款 行(当前批次无对应支出记录,需 Go 侧跨批次核销)
|
||||||
|
expense_merchants = {exp[2].strip() for exp in expense_rows}
|
||||||
|
for refund_row in refund_rows:
|
||||||
|
if refund_row[2].strip() not in expense_merchants:
|
||||||
|
amount = float(parse_amount(refund_row[5]))
|
||||||
|
if amount > 0:
|
||||||
|
self.unresolved_refunds.append({
|
||||||
|
"order_no": "",
|
||||||
|
"merchant_order_no": refund_row[9].strip() if len(refund_row) > 9 else "",
|
||||||
|
"refund_order_no": refund_row[8].strip() if len(refund_row) > 8 else "",
|
||||||
|
"amount": amount,
|
||||||
|
"time": refund_row[0],
|
||||||
|
"merchant": refund_row[2],
|
||||||
|
"description": refund_row[3] if len(refund_row) > 3 else "",
|
||||||
|
})
|
||||||
|
|
||||||
print(f"\n处理结果:")
|
print(f"\n处理结果:")
|
||||||
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
||||||
print(f" 部分退款调整: {self.stats['partially_refunded']} 条")
|
print(f" 部分退款调整: {self.stats['partially_refunded']} 条")
|
||||||
|
|||||||
@@ -66,8 +66,6 @@
|
|||||||
- 高速
|
- 高速
|
||||||
- 过路费
|
- 过路费
|
||||||
- "12306"
|
- "12306"
|
||||||
- 携程
|
|
||||||
- 飞猪
|
|
||||||
|
|
||||||
# 充值缴费
|
# 充值缴费
|
||||||
充值缴费:
|
充值缴费:
|
||||||
@@ -84,8 +82,8 @@
|
|||||||
- 供暖
|
- 供暖
|
||||||
- 暖气
|
- 暖气
|
||||||
|
|
||||||
# 宠物用品
|
# 宠物相关
|
||||||
宠物用品:
|
宠物相关:
|
||||||
- 宠物
|
- 宠物
|
||||||
- 猫咪
|
- 猫咪
|
||||||
- 狗
|
- 狗
|
||||||
@@ -140,6 +138,31 @@
|
|||||||
- 攀岩
|
- 攀岩
|
||||||
- 骑行装备
|
- 骑行装备
|
||||||
|
|
||||||
|
# 酒店旅游(优先级高于文化休闲)
|
||||||
|
酒店旅游:
|
||||||
|
- 酒店
|
||||||
|
- 宾馆
|
||||||
|
- 民宿
|
||||||
|
- 客栈
|
||||||
|
- 旅馆
|
||||||
|
- 如家
|
||||||
|
- 汉庭
|
||||||
|
- 7天
|
||||||
|
- 希尔顿
|
||||||
|
- 万豪
|
||||||
|
- 喜来登
|
||||||
|
- 亚朵
|
||||||
|
- 华住
|
||||||
|
- 携程
|
||||||
|
- 飞猪
|
||||||
|
- 去哪儿
|
||||||
|
- 同程
|
||||||
|
- 旅游
|
||||||
|
- 旅行
|
||||||
|
- 景区
|
||||||
|
- 门票
|
||||||
|
- 度假
|
||||||
|
|
||||||
# 文化休闲(优先级高于日用百货,避免万达影城被识别为万达商场)
|
# 文化休闲(优先级高于日用百货,避免万达影城被识别为万达商场)
|
||||||
文化休闲:
|
文化休闲:
|
||||||
- 影城 # 电影院
|
- 影城 # 电影院
|
||||||
@@ -150,9 +173,6 @@
|
|||||||
- 书
|
- 书
|
||||||
- 图书
|
- 图书
|
||||||
- 文娱
|
- 文娱
|
||||||
- 旅游
|
|
||||||
- 景区
|
|
||||||
- 门票
|
|
||||||
- 会员
|
- 会员
|
||||||
- 视频
|
- 视频
|
||||||
- 音乐
|
- 音乐
|
||||||
@@ -164,11 +184,6 @@
|
|||||||
- 酒吧
|
- 酒吧
|
||||||
- 演出
|
- 演出
|
||||||
- 演唱会
|
- 演唱会
|
||||||
- 酒店 # 美团酒店
|
|
||||||
- 如家
|
|
||||||
- 汉庭
|
|
||||||
- 7天
|
|
||||||
- 民宿
|
|
||||||
|
|
||||||
# 日用百货 - 盒马放在前面,避免被餐饮匹配
|
# 日用百货 - 盒马放在前面,避免被餐饮匹配
|
||||||
日用百货:
|
日用百货:
|
||||||
@@ -266,11 +281,6 @@
|
|||||||
- 粒上皇 # 炒货零食店
|
- 粒上皇 # 炒货零食店
|
||||||
- 盒马
|
- 盒马
|
||||||
|
|
||||||
# 转账红包
|
|
||||||
转账红包:
|
|
||||||
- 红包
|
|
||||||
- 转账给
|
|
||||||
|
|
||||||
|
|
||||||
收入分类:
|
收入分类:
|
||||||
退款:
|
退款:
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class CleanResponse(BaseModel):
|
|||||||
bill_type: str
|
bill_type: str
|
||||||
message: str
|
message: str
|
||||||
output_path: Optional[str] = None
|
output_path: Optional[str] = None
|
||||||
|
unresolved_refunds: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
class CategoryRequest(BaseModel):
|
class CategoryRequest(BaseModel):
|
||||||
@@ -138,22 +139,22 @@ def do_clean(
|
|||||||
start: str = None,
|
start: str = None,
|
||||||
end: str = None,
|
end: str = None,
|
||||||
output_format: str = "csv"
|
output_format: str = "csv"
|
||||||
) -> tuple[bool, str, str]:
|
) -> tuple[bool, str, str, list[dict]]:
|
||||||
"""
|
"""
|
||||||
执行清洗逻辑
|
执行清洗逻辑
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(success, bill_type, message)
|
(success, bill_type, message, unresolved_refunds)
|
||||||
"""
|
"""
|
||||||
# 检查文件是否存在
|
# 检查文件是否存在
|
||||||
if not Path(input_path).exists():
|
if not Path(input_path).exists():
|
||||||
return False, "", f"文件不存在: {input_path}"
|
return False, "", f"文件不存在: {input_path}", []
|
||||||
|
|
||||||
# 检测账单类型
|
# 检测账单类型
|
||||||
if bill_type == "auto":
|
if bill_type == "auto":
|
||||||
detected_type = detect_bill_type(input_path)
|
detected_type = detect_bill_type(input_path)
|
||||||
if detected_type is None:
|
if detected_type is None:
|
||||||
return False, "", "无法识别账单类型"
|
return False, "", "无法识别账单类型", []
|
||||||
bill_type = detected_type
|
bill_type = detected_type
|
||||||
|
|
||||||
# 计算日期范围
|
# 计算日期范围
|
||||||
@@ -172,10 +173,10 @@ def do_clean(
|
|||||||
cleaner.clean()
|
cleaner.clean()
|
||||||
|
|
||||||
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
|
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
|
||||||
return True, bill_type, f"✅ {type_names.get(bill_type, bill_type)}账单清洗完成"
|
return True, bill_type, f"✅ {type_names.get(bill_type, bill_type)}账单清洗完成", cleaner.unresolved_refunds
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, bill_type, f"清洗失败: {str(e)}"
|
return False, bill_type, f"清洗失败: {str(e)}", []
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -215,7 +216,7 @@ async def clean_bill(request: CleanRequest):
|
|||||||
|
|
||||||
接收账单文件路径,执行清洗后输出到指定路径
|
接收账单文件路径,执行清洗后输出到指定路径
|
||||||
"""
|
"""
|
||||||
success, bill_type, message = do_clean(
|
success, bill_type, message, unresolved_refunds = do_clean(
|
||||||
input_path=request.input_path,
|
input_path=request.input_path,
|
||||||
output_path=request.output_path,
|
output_path=request.output_path,
|
||||||
bill_type=request.bill_type or "auto",
|
bill_type=request.bill_type or "auto",
|
||||||
@@ -233,7 +234,8 @@ async def clean_bill(request: CleanRequest):
|
|||||||
success=True,
|
success=True,
|
||||||
bill_type=bill_type,
|
bill_type=bill_type,
|
||||||
message=message,
|
message=message,
|
||||||
output_path=request.output_path
|
output_path=request.output_path,
|
||||||
|
unresolved_refunds=unresolved_refunds
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -264,7 +266,7 @@ async def clean_bill_upload(
|
|||||||
output_path = tmp_output.name
|
output_path = tmp_output.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success, detected_type, message = do_clean(
|
success, detected_type, message, unresolved_refunds = do_clean(
|
||||||
input_path=input_path,
|
input_path=input_path,
|
||||||
output_path=output_path,
|
output_path=output_path,
|
||||||
bill_type=bill_type or "auto",
|
bill_type=bill_type or "auto",
|
||||||
@@ -282,7 +284,8 @@ async def clean_bill_upload(
|
|||||||
success=True,
|
success=True,
|
||||||
bill_type=detected_type,
|
bill_type=detected_type,
|
||||||
message=message,
|
message=message,
|
||||||
output_path=output_path
|
output_path=output_path,
|
||||||
|
unresolved_refunds=unresolved_refunds
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import sys
|
|||||||
sys.stdout.reconfigure(encoding='utf-8')
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
def test_jd_cleaner():
|
def test_jd_cleaner():
|
||||||
zip_path = r'D:\Projects\BillAI\mock_data\京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip'
|
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
zip_path = os.path.join(base_dir, 'mock_data', '京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip')
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
|||||||
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
|
||||||
@@ -27,8 +27,8 @@ services:
|
|||||||
# Go 后端服务
|
# Go 后端服务
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
container_name: billai-server
|
container_name: billai-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
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
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
# Go 服务 Dockerfile
|
# Go 服务 Dockerfile
|
||||||
# 多阶段构建:编译阶段 + 运行阶段
|
# 多阶段构建:编译阶段 + 运行阶段
|
||||||
|
# 构建上下文:项目根目录(docker-compose context: .)
|
||||||
|
|
||||||
# ===== 编译阶段 =====
|
# ===== 编译阶段 =====
|
||||||
FROM golang:1.21-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -10,11 +11,11 @@ WORKDIR /build
|
|||||||
ENV GOPROXY=https://goproxy.cn,direct
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
|
||||||
# 先复制依赖文件,利用 Docker 缓存
|
# 先复制依赖文件,利用 Docker 缓存
|
||||||
COPY go.mod go.sum ./
|
COPY server/go.mod server/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# 复制源代码并编译
|
# 复制源代码并编译
|
||||||
COPY . .
|
COPY server/ .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o billai-server .
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o billai-server .
|
||||||
|
|
||||||
# ===== 运行阶段 =====
|
# ===== 运行阶段 =====
|
||||||
@@ -35,6 +36,9 @@ ENV TZ=Asia/Shanghai
|
|||||||
COPY --from=builder /build/billai-server .
|
COPY --from=builder /build/billai-server .
|
||||||
COPY --from=builder /build/config.yaml .
|
COPY --from=builder /build/config.yaml .
|
||||||
|
|
||||||
|
# 复制项目根目录的 CHANGELOG.md
|
||||||
|
COPY CHANGELOG.md .
|
||||||
|
|
||||||
# 创建必要目录
|
# 创建必要目录
|
||||||
RUN mkdir -p uploads outputs
|
RUN mkdir -p uploads outputs
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,20 @@ type CleanOptions struct {
|
|||||||
|
|
||||||
// CleanResult 清洗结果
|
// CleanResult 清洗结果
|
||||||
type CleanResult struct {
|
type CleanResult struct {
|
||||||
BillType string // 检测到的账单类型: alipay/wechat/jd
|
BillType string // 检测到的账单类型: alipay/wechat/jd
|
||||||
Output string // 脚本输出信息
|
Output string // 脚本输出信息
|
||||||
|
UnresolvedRefunds []UnresolvedRefund // 本次清洗未在同批次内匹配到对应支出的退款
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnresolvedRefund 本次清洗未在同批次内匹配到对应支出的退款
|
||||||
|
type UnresolvedRefund struct {
|
||||||
|
OrderNo string // 原订单号(去除退款后缀)
|
||||||
|
MerchantOrderNo string // 商家订单号(备用匹配字段)
|
||||||
|
RefundOrderNo string // 退款行自身的完整订单号(用于备注追溯)
|
||||||
|
Amount float64 // 退款金额
|
||||||
|
Time string // 退款时间
|
||||||
|
Merchant string // 交易对方
|
||||||
|
Description string // 商品说明
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertResult 格式转换结果
|
// ConvertResult 格式转换结果
|
||||||
|
|||||||
@@ -29,10 +29,22 @@ type CleanRequest struct {
|
|||||||
|
|
||||||
// CleanResponse HTTP 清洗响应
|
// CleanResponse HTTP 清洗响应
|
||||||
type CleanResponse struct {
|
type CleanResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
BillType string `json:"bill_type"`
|
BillType string `json:"bill_type"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
OutputPath string `json:"output_path,omitempty"`
|
OutputPath string `json:"output_path,omitempty"`
|
||||||
|
UnresolvedRefunds []UnresolvedRefund `json:"unresolved_refunds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnresolvedRefund 本次清洗未在同批次内匹配到对应支出的退款(与 Python 端 dict 字段对应)
|
||||||
|
type UnresolvedRefund struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
MerchantOrderNo string `json:"merchant_order_no"`
|
||||||
|
RefundOrderNo string `json:"refund_order_no"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
Merchant string `json:"merchant"`
|
||||||
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrorResponse 错误响应
|
// ErrorResponse 错误响应
|
||||||
@@ -149,9 +161,23 @@ func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unresolvedRefunds := make([]adapter.UnresolvedRefund, 0, len(cleanResp.UnresolvedRefunds))
|
||||||
|
for _, ur := range cleanResp.UnresolvedRefunds {
|
||||||
|
unresolvedRefunds = append(unresolvedRefunds, adapter.UnresolvedRefund{
|
||||||
|
OrderNo: ur.OrderNo,
|
||||||
|
MerchantOrderNo: ur.MerchantOrderNo,
|
||||||
|
RefundOrderNo: ur.RefundOrderNo,
|
||||||
|
Amount: ur.Amount,
|
||||||
|
Time: ur.Time,
|
||||||
|
Merchant: ur.Merchant,
|
||||||
|
Description: ur.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return &adapter.CleanResult{
|
return &adapter.CleanResult{
|
||||||
BillType: cleanResp.BillType,
|
BillType: cleanResp.BillType,
|
||||||
Output: cleanResp.Message,
|
Output: cleanResp.Message,
|
||||||
|
UnresolvedRefunds: unresolvedRefunds,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# BillAI 服务器配置文件
|
# BillAI 服务器配置文件
|
||||||
|
|
||||||
# 应用版本
|
# 应用版本
|
||||||
version: "1.0.7"
|
version: "1.0.8"
|
||||||
|
|
||||||
# 服务配置
|
# 服务配置
|
||||||
server:
|
server:
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
module billai-server
|
module billai-server
|
||||||
|
|
||||||
go 1.21
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1
|
||||||
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9
|
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9
|
||||||
go.mongodb.org/mongo-driver v1.13.1
|
go.mongodb.org/mongo-driver v1.13.1
|
||||||
golang.org/x/text v0.9.0
|
golang.org/x/text v0.34.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,16 +31,21 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.1.2 // indirect
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/crypto v0.9.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
|
|||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -63,8 +67,11 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
@@ -75,6 +82,12 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
|||||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M=
|
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M=
|
||||||
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI=
|
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
|
||||||
@@ -88,18 +101,21 @@ golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -108,8 +124,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@@ -118,8 +134,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ func Login(c *gin.Context) {
|
|||||||
|
|
||||||
secret := config.Global.JWTSecret
|
secret := config.Global.JWTSecret
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
secret = "billai-default-secret"
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "服务器 JWT 配置缺失",
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
@@ -144,7 +148,12 @@ func ValidateToken(c *gin.Context) {
|
|||||||
|
|
||||||
secret := config.Global.JWTSecret
|
secret := config.Global.JWTSecret
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
secret = "billai-default-secret"
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "服务器 JWT 配置缺失",
|
||||||
|
"code": "TOKEN_INVALID",
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -154,18 +153,6 @@ func ListBills(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePageParam 解析分页参数
|
|
||||||
func parsePageParam(s string, defaultVal int) int {
|
|
||||||
if s == "" {
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
val, err := strconv.Atoi(s)
|
|
||||||
if err != nil || val < 1 {
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// MonthlyStatsResponse 月度统计响应
|
// MonthlyStatsResponse 月度统计响应
|
||||||
type MonthlyStatsResponse struct {
|
type MonthlyStatsResponse struct {
|
||||||
Result bool `json:"result"`
|
Result bool `json:"result"`
|
||||||
@@ -202,6 +189,13 @@ func MonthlyStats(c *gin.Context) {
|
|||||||
// ReviewStats 获取待复核数据统计
|
// ReviewStats 获取待复核数据统计
|
||||||
func ReviewStats(c *gin.Context) {
|
func ReviewStats(c *gin.Context) {
|
||||||
repo := repository.GetRepository()
|
repo := repository.GetRepository()
|
||||||
|
if repo == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, model.ReviewResponse{
|
||||||
|
Result: false,
|
||||||
|
Message: "数据库未连接",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 从MongoDB查询所有需要复核的账单
|
// 从MongoDB查询所有需要复核的账单
|
||||||
bills, err := repo.GetBillsNeedReview()
|
bills, err := repo.GetBillsNeedReview()
|
||||||
|
|||||||
26
server/handler/changelog.go
Normal file
26
server/handler/changelog.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"billai-server/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetChangelog GET /api/changelog 获取版本变更日志
|
||||||
|
func GetChangelog(c *gin.Context) {
|
||||||
|
changelog, err := service.ParseChangelog()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"result": false,
|
||||||
|
"message": "获取变更日志失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"result": true,
|
||||||
|
"data": changelog,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
134
server/handler/export.go
Normal file
134
server/handler/export.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
|
||||||
|
"billai-server/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExportBillsRequest struct {
|
||||||
|
StartDate string `form:"start_date"`
|
||||||
|
EndDate string `form:"end_date"`
|
||||||
|
Category string `form:"category"`
|
||||||
|
Type string `form:"type"`
|
||||||
|
IncomeExpense string `form:"income_expense"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportBills(c *gin.Context) {
|
||||||
|
var req ExportBillsRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"result": false,
|
||||||
|
"message": "参数解析失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := buildFilterFromRequest(req)
|
||||||
|
|
||||||
|
repo := repository.GetRepository()
|
||||||
|
if repo == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"result": false,
|
||||||
|
"message": "数据库未连接",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bills, err := repo.GetCleanedBills(filter)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"result": false,
|
||||||
|
"message": "查询失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f := excelize.NewFile()
|
||||||
|
sheet := "账单"
|
||||||
|
f.SetSheetName("Sheet1", sheet)
|
||||||
|
|
||||||
|
headers := []string{"时间", "来源", "分类", "交易对方", "商品说明", "收/支", "金额", "支付方式", "状态", "备注"}
|
||||||
|
for i, header := range headers {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||||
|
f.SetCellValue(sheet, cell, header)
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, bill := range bills {
|
||||||
|
row := idx + 2
|
||||||
|
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), bill.Time.Time().Format("2006-01-02 15:04:05"))
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("B%d", row), bill.BillType)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("C%d", row), bill.Category)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("D%d", row), bill.Merchant)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), bill.Description)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), bill.IncomeExpense)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("G%d", row), bill.Amount)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("H%d", row), bill.PayMethod)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("I%d", row), bill.Status)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("J%d", row), bill.Remark)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.SetColWidth(sheet, "A", "A", 20)
|
||||||
|
f.SetColWidth(sheet, "B", "B", 8)
|
||||||
|
f.SetColWidth(sheet, "C", "C", 12)
|
||||||
|
f.SetColWidth(sheet, "D", "D", 20)
|
||||||
|
f.SetColWidth(sheet, "E", "E", 30)
|
||||||
|
f.SetColWidth(sheet, "F", "F", 8)
|
||||||
|
f.SetColWidth(sheet, "G", "G", 12)
|
||||||
|
f.SetColWidth(sheet, "H", "H", 15)
|
||||||
|
f.SetColWidth(sheet, "I", "I", 10)
|
||||||
|
f.SetColWidth(sheet, "J", "J", 20)
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("bills_%s.xlsx", time.Now().Format("20060102_150405"))
|
||||||
|
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
c.Header("Access-Control-Expose-Headers", "Content-Disposition")
|
||||||
|
|
||||||
|
if err := f.Write(c.Writer); err != nil {
|
||||||
|
fmt.Printf("导出 Excel 失败: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFilterFromRequest(req ExportBillsRequest) map[string]interface{} {
|
||||||
|
filter := make(map[string]interface{})
|
||||||
|
|
||||||
|
if req.StartDate != "" || req.EndDate != "" {
|
||||||
|
timeFilter := make(map[string]interface{})
|
||||||
|
if req.StartDate != "" {
|
||||||
|
startTime, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local)
|
||||||
|
if err == nil {
|
||||||
|
timeFilter["$gte"] = startTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.EndDate != "" {
|
||||||
|
endTime, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local)
|
||||||
|
if err == nil {
|
||||||
|
endTime = endTime.Add(24 * time.Hour)
|
||||||
|
timeFilter["$lt"] = endTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(timeFilter) > 0 {
|
||||||
|
filter["time"] = timeFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Category != "" {
|
||||||
|
filter["category"] = req.Category
|
||||||
|
}
|
||||||
|
if req.Type != "" {
|
||||||
|
filter["bill_type"] = req.Type
|
||||||
|
}
|
||||||
|
if req.IncomeExpense != "" {
|
||||||
|
filter["income_expense"] = req.IncomeExpense
|
||||||
|
}
|
||||||
|
|
||||||
|
filter["is_deleted"] = false
|
||||||
|
|
||||||
|
return filter
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ func Upload(c *gin.Context) {
|
|||||||
End: req.End,
|
End: req.End,
|
||||||
Format: req.Format,
|
Format: req.Format,
|
||||||
}
|
}
|
||||||
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
||||||
if cleanErr != nil {
|
if cleanErr != nil {
|
||||||
service.CleanupExtractedFiles(extractedFiles)
|
service.CleanupExtractedFiles(extractedFiles)
|
||||||
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||||
@@ -255,22 +255,56 @@ func Upload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
service.CleanupExtractedFiles(extractedFiles)
|
service.CleanupExtractedFiles(extractedFiles)
|
||||||
|
|
||||||
|
repo := repository.GetRepository()
|
||||||
|
|
||||||
// 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录
|
// 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录
|
||||||
var jdRelatedDeleted int64
|
var jdRelatedDeleted int64
|
||||||
if billType == "jd" {
|
if billType == "jd" && repo != nil {
|
||||||
repo := repository.GetRepository()
|
deleted, err := repo.SoftDeleteJDRelatedBills()
|
||||||
if repo != nil {
|
if err != nil {
|
||||||
deleted, err := repo.SoftDeleteJDRelatedBills()
|
fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err)
|
||||||
if err != nil {
|
} else if deleted > 0 {
|
||||||
fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err)
|
jdRelatedDeleted = deleted
|
||||||
} else if deleted > 0 {
|
fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted)
|
||||||
jdRelatedDeleted = deleted
|
}
|
||||||
fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted)
|
}
|
||||||
|
|
||||||
|
// 14. 核销跨批次退款(支付宝:本次清洗中未在同批次内匹配到对应支出的退款)
|
||||||
|
var reconciledCount int
|
||||||
|
if repo != nil && cleanResult != nil {
|
||||||
|
for _, ur := range cleanResult.UnresolvedRefunds {
|
||||||
|
matched, rErr := repo.ReconcileRefund(billType, ur.OrderNo, ur.MerchantOrderNo, ur.Amount, ur.Time, ur.Merchant, ur.Description, ur.RefundOrderNo)
|
||||||
|
if rErr != nil {
|
||||||
|
fmt.Printf("⚠️ 退款核销失败: %v\n", rErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
reconciledCount++
|
||||||
|
fmt.Printf("💰 已核销退款: 订单%s, 金额%.2f元\n", ur.OrderNo, ur.Amount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 14. 返回成功响应
|
// 15. 核销跨批次微信退款(重复上传的行中携带的已退款状态)
|
||||||
|
if repo != nil && len(dedupResult.WechatRefundUpdates) > 0 {
|
||||||
|
for _, wu := range dedupResult.WechatRefundUpdates {
|
||||||
|
matched, rErr := repo.ReconcileWechatRefund(wu.TransactionID, wu.FullRefund, wu.CumulativeRefundAmount)
|
||||||
|
if rErr != nil {
|
||||||
|
fmt.Printf("⚠️ 微信退款核销失败: %v\n", rErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
reconciledCount++
|
||||||
|
if wu.FullRefund {
|
||||||
|
fmt.Printf("💰 已核销微信全额退款: 交易单号%s\n", wu.TransactionID)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("💰 已核销微信部分退款: 交易单号%s, 累计退款%.2f元\n", wu.TransactionID, wu.CumulativeRefundAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. 返回成功响应
|
||||||
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)
|
||||||
@@ -278,18 +312,22 @@ func Upload(c *gin.Context) {
|
|||||||
if jdRelatedDeleted > 0 {
|
if jdRelatedDeleted > 0 {
|
||||||
message = fmt.Sprintf("%s,标记删除 %d 条重复的京东订单", message, jdRelatedDeleted)
|
message = fmt.Sprintf("%s,标记删除 %d 条重复的京东订单", message, jdRelatedDeleted)
|
||||||
}
|
}
|
||||||
|
if reconciledCount > 0 {
|
||||||
|
message = fmt.Sprintf("%s,核销退款 %d 条", message, reconciledCount)
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
JDRelatedDeleted: jdRelatedDeleted,
|
||||||
|
ReconciledRefundCount: reconciledCount,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ func AuthRequired() gin.HandlerFunc {
|
|||||||
|
|
||||||
secret := config.Global.JWTSecret
|
secret := config.Global.JWTSecret
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
secret = "billai-default-secret"
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "服务器 JWT 配置缺失",
|
||||||
|
"code": "TOKEN_INVALID",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
|||||||
@@ -88,9 +88,10 @@ type CleanedBill struct {
|
|||||||
Category string `bson:"category" json:"category"` // 交易分类
|
Category string `bson:"category" json:"category"` // 交易分类
|
||||||
Merchant string `bson:"merchant" json:"merchant"` // 交易对方
|
Merchant string `bson:"merchant" json:"merchant"` // 交易对方
|
||||||
Description string `bson:"description" json:"description"` // 商品说明
|
Description string `bson:"description" json:"description"` // 商品说明
|
||||||
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
|
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
|
||||||
Amount float64 `bson:"amount" json:"amount"` // 金额
|
Amount float64 `bson:"amount" json:"amount"` // 金额
|
||||||
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
|
OriginalAmount float64 `bson:"original_amount,omitempty" json:"original_amount,omitempty"` // 原始金额(入库时),用于微信跨批次退款核销
|
||||||
|
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
|
||||||
Status string `bson:"status" json:"status"` // 交易状态
|
Status string `bson:"status" json:"status"` // 交易状态
|
||||||
Remark string `bson:"remark" json:"remark"` // 备注
|
Remark string `bson:"remark" json:"remark"` // 备注
|
||||||
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
|
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package model
|
|||||||
|
|
||||||
// UploadData 上传响应数据
|
// UploadData 上传响应数据
|
||||||
type UploadData struct {
|
type UploadData struct {
|
||||||
BillType string `json:"bill_type,omitempty"` // alipay/wechat/jd
|
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"` // 软删除的京东关联记录数(其他来源中描述包含京东订单号的记录)
|
JDRelatedDeleted int64 `json:"jd_related_deleted,omitempty"` // 软删除的京东关联记录数(其他来源中描述包含京东订单号的记录)
|
||||||
|
ReconciledRefundCount int `json:"reconciled_refund_count,omitempty"` // 跨批次核销的退款记录数
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResponse 上传响应
|
// UploadResponse 上传响应
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package mongo
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/bson"
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
@@ -16,6 +17,9 @@ import (
|
|||||||
"billai-server/repository"
|
"billai-server/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// refundEpsilon 退款核销后剩余金额的容差阈值,小于该值视为已全额退款
|
||||||
|
const refundEpsilon = 0.005
|
||||||
|
|
||||||
// Repository MongoDB 账单存储实现
|
// Repository MongoDB 账单存储实现
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
client *mongo.Client
|
client *mongo.Client
|
||||||
@@ -445,7 +449,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)
|
||||||
@@ -493,6 +502,149 @@ func (r *Repository) SoftDeleteJDRelatedBills() (int64, error) {
|
|||||||
return result.ModifiedCount, nil
|
return result.ModifiedCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReconcileRefund 将跨批次退款核销到已存储的清洗后账单
|
||||||
|
// 按 bill_type + (transaction_id == orderNo 或 merchant_order_no == merchantOrderNo) 查找未删除记录
|
||||||
|
// 全额退款(剩余金额 <= refundEpsilon)则软删除;部分退款则扣减 amount 并追加备注
|
||||||
|
func (r *Repository) ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (bool, error) {
|
||||||
|
if r.cleanedCollection == nil {
|
||||||
|
return false, fmt.Errorf("cleaned collection not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if orderNo == "" && merchantOrderNo == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var or []bson.M
|
||||||
|
if orderNo != "" {
|
||||||
|
or = append(or, bson.M{"transaction_id": orderNo})
|
||||||
|
}
|
||||||
|
if merchantOrderNo != "" {
|
||||||
|
or = append(or, bson.M{"merchant_order_no": merchantOrderNo})
|
||||||
|
}
|
||||||
|
filter := bson.M{
|
||||||
|
"bill_type": billType,
|
||||||
|
"is_deleted": bson.M{"$ne": true},
|
||||||
|
"$or": or,
|
||||||
|
}
|
||||||
|
|
||||||
|
var bill model.CleanedBill
|
||||||
|
if err := r.cleanedCollection.FindOne(ctx, filter).Decode(&bill); err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("查询待核销账单失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := bill.Amount - refundAmount
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if remaining <= refundEpsilon {
|
||||||
|
update := bson.M{"$set": bson.M{
|
||||||
|
"is_deleted": true,
|
||||||
|
"updated_at": now,
|
||||||
|
"remark": fmt.Sprintf("[退款核销]全额退款%.2f元(退款单号%s);%s", refundAmount, refundOrderNo, bill.Remark),
|
||||||
|
}}
|
||||||
|
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("核销退款失败: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = math.Round(remaining*100) / 100
|
||||||
|
update := bson.M{"$set": bson.M{
|
||||||
|
"amount": remaining,
|
||||||
|
"updated_at": now,
|
||||||
|
"remark": fmt.Sprintf("原金额%.2f元,退款%.2f元(退款单号%s);%s", bill.Amount, refundAmount, refundOrderNo, bill.Remark),
|
||||||
|
}}
|
||||||
|
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("核销退款失败: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileWechatRefund 将跨批次微信退款核销到已存储的清洗后账单
|
||||||
|
// 微信"当前状态"字段为累计退款金额,使用 original_amount(入库时原始金额)计算剩余
|
||||||
|
func (r *Repository) ReconcileWechatRefund(transactionID string, fullRefund bool, cumulativeRefundAmount float64) (bool, error) {
|
||||||
|
if r.cleanedCollection == nil {
|
||||||
|
return false, fmt.Errorf("cleaned collection not initialized")
|
||||||
|
}
|
||||||
|
if transactionID == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
filter := bson.M{
|
||||||
|
"bill_type": "wechat",
|
||||||
|
"transaction_id": transactionID,
|
||||||
|
"is_deleted": bson.M{"$ne": true},
|
||||||
|
}
|
||||||
|
|
||||||
|
var bill model.CleanedBill
|
||||||
|
if err := r.cleanedCollection.FindOne(ctx, filter).Decode(&bill); err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("查询待核销微信账单失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if fullRefund {
|
||||||
|
update := bson.M{"$set": bson.M{
|
||||||
|
"is_deleted": true,
|
||||||
|
"updated_at": now,
|
||||||
|
"remark": fmt.Sprintf("[退款核销]全额退款;%s", bill.Remark),
|
||||||
|
}}
|
||||||
|
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("核销微信全额退款失败: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部分退款:用原始金额(original_amount)减去累计退款金额得到剩余
|
||||||
|
originalAmount := bill.OriginalAmount
|
||||||
|
if originalAmount <= 0 {
|
||||||
|
originalAmount = bill.Amount // 兼容旧记录(无 original_amount 字段)
|
||||||
|
}
|
||||||
|
remaining := originalAmount - cumulativeRefundAmount
|
||||||
|
|
||||||
|
if remaining <= refundEpsilon {
|
||||||
|
update := bson.M{"$set": bson.M{
|
||||||
|
"is_deleted": true,
|
||||||
|
"updated_at": now,
|
||||||
|
"remark": fmt.Sprintf("[退款核销]全额退款%.2f元(原金额%.2f元);%s", cumulativeRefundAmount, originalAmount, bill.Remark),
|
||||||
|
}}
|
||||||
|
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("核销微信退款失败: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = math.Round(remaining*100) / 100
|
||||||
|
update := bson.M{"$set": bson.M{
|
||||||
|
"amount": remaining,
|
||||||
|
"updated_at": now,
|
||||||
|
"remark": fmt.Sprintf("原金额%.2f元,已退款%.2f元;%s", originalAmount, cumulativeRefundAmount, bill.Remark),
|
||||||
|
}}
|
||||||
|
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("核销微信退款失败: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建议: 为提升查询性能,可为 bills_cleaned 添加索引
|
||||||
|
// {transaction_id:1, bill_type:1} 和 {merchant_order_no:1, bill_type:1}
|
||||||
|
|
||||||
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||||
func (r *Repository) GetClient() *mongo.Client {
|
func (r *Repository) GetClient() *mongo.Client {
|
||||||
return r.client
|
return r.client
|
||||||
|
|||||||
@@ -56,4 +56,16 @@ type BillRepository interface {
|
|||||||
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
|
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
|
||||||
// 返回: 删除数量、错误
|
// 返回: 删除数量、错误
|
||||||
SoftDeleteJDRelatedBills() (int64, error)
|
SoftDeleteJDRelatedBills() (int64, error)
|
||||||
|
|
||||||
|
// ReconcileRefund 将跨批次退款核销到已存储的清洗后账单
|
||||||
|
// 按 bill_type + (transaction_id == orderNo 或 merchant_order_no == merchantOrderNo) 查找未删除记录
|
||||||
|
// 全额退款(剩余金额 <= 0.005)则软删除;部分退款则扣减 amount 并追加备注
|
||||||
|
// 返回: 是否找到并核销了匹配记录、错误(未找到匹配记录不算错误,返回 matched=false)
|
||||||
|
ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (matched bool, err error)
|
||||||
|
|
||||||
|
// ReconcileWechatRefund 将跨批次微信退款核销到已存储的清洗后账单
|
||||||
|
// 微信退款通过重复上传时"当前状态"字段携带退款信息来触发
|
||||||
|
// fullRefund=true 时软删除原记录;否则用 original_amount - cumulativeRefundAmount 计算剩余金额
|
||||||
|
// 返回: 是否找到并核销了匹配记录、错误
|
||||||
|
ReconcileWechatRefund(transactionID string, fullRefund bool, cumulativeRefundAmount float64) (matched bool, err error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ func setupAPIRoutes(r *gin.Engine) {
|
|||||||
api.POST("/auth/login", handler.Login)
|
api.POST("/auth/login", handler.Login)
|
||||||
api.GET("/auth/validate", handler.ValidateToken)
|
api.GET("/auth/validate", handler.ValidateToken)
|
||||||
|
|
||||||
|
// 公开接口(无需登录)
|
||||||
|
api.GET("/changelog", handler.GetChangelog)
|
||||||
|
|
||||||
// 需要登录的 API
|
// 需要登录的 API
|
||||||
authed := api.Group("/")
|
authed := api.Group("/")
|
||||||
authed.Use(middleware.AuthRequired())
|
authed.Use(middleware.AuthRequired())
|
||||||
@@ -59,11 +62,14 @@ func setupAPIRoutes(r *gin.Engine) {
|
|||||||
// 账单查询
|
// 账单查询
|
||||||
authed.GET("/bills", handler.ListBills)
|
authed.GET("/bills", handler.ListBills)
|
||||||
|
|
||||||
|
// 导出账单
|
||||||
|
authed.GET("/bills/export", handler.ExportBills)
|
||||||
|
|
||||||
// 编辑账单
|
// 编辑账单
|
||||||
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)
|
||||||
|
|||||||
@@ -23,13 +23,21 @@ func getRepo() repository.BillRepository {
|
|||||||
return repository.GetRepository()
|
return repository.GetRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WechatRefundUpdate 微信重复行中携带的退款信息(用于跨批次退款核销)
|
||||||
|
type WechatRefundUpdate struct {
|
||||||
|
TransactionID string // 原消费行的交易单号
|
||||||
|
FullRefund bool // 是否全额退款(已全额退款)
|
||||||
|
CumulativeRefundAmount float64 // 累计退款金额(已退款(¥X)中的 X,与原始金额相减得剩余)
|
||||||
|
}
|
||||||
|
|
||||||
// DeduplicateResult 去重结果
|
// DeduplicateResult 去重结果
|
||||||
type DeduplicateResult struct {
|
type DeduplicateResult struct {
|
||||||
OriginalCount int // 原始记录数
|
OriginalCount int // 原始记录数
|
||||||
DuplicateCount int // 重复记录数
|
DuplicateCount int // 重复记录数
|
||||||
NewCount int // 新记录数
|
NewCount int // 新记录数
|
||||||
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
|
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
|
||||||
BillType string // 检测到的账单类型
|
BillType string // 检测到的账单类型
|
||||||
|
WechatRefundUpdates []WechatRefundUpdate // 微信重复行中检测到的退款状态(用于跨批次核销)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
|
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
|
||||||
@@ -75,6 +83,17 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对于微信账单,找到"当前状态"列的索引,用于检测退款状态
|
||||||
|
wechatStatusIdx := -1
|
||||||
|
if billType == "wechat" {
|
||||||
|
for i, col := range header {
|
||||||
|
if col == "当前状态" {
|
||||||
|
wechatStatusIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查每行是否重复
|
// 检查每行是否重复
|
||||||
var newRows [][]string
|
var newRows [][]string
|
||||||
for _, row := range dataRows {
|
for _, row := range dataRows {
|
||||||
@@ -101,6 +120,24 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
|||||||
newRows = append(newRows, row)
|
newRows = append(newRows, row)
|
||||||
} else {
|
} else {
|
||||||
result.DuplicateCount++
|
result.DuplicateCount++
|
||||||
|
// 微信账单:检查重复行是否携带退款状态(跨批次退款核销)
|
||||||
|
if wechatStatusIdx >= 0 && len(row) > wechatStatusIdx {
|
||||||
|
status := strings.TrimSpace(row[wechatStatusIdx])
|
||||||
|
if strings.Contains(status, "已全额退款") {
|
||||||
|
result.WechatRefundUpdates = append(result.WechatRefundUpdates, WechatRefundUpdate{
|
||||||
|
TransactionID: transactionID,
|
||||||
|
FullRefund: true,
|
||||||
|
})
|
||||||
|
} else if strings.Contains(status, "已退款") {
|
||||||
|
if amount := extractWechatRefundAmount(status); amount > 0 {
|
||||||
|
result.WechatRefundUpdates = append(result.WechatRefundUpdates, WechatRefundUpdate{
|
||||||
|
TransactionID: transactionID,
|
||||||
|
FullRefund: false,
|
||||||
|
CumulativeRefundAmount: amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +374,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
|
|||||||
bill.ReviewLevel = row[idx]
|
bill.ReviewLevel = row[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bill.OriginalAmount = bill.Amount
|
||||||
bills = append(bills, bill)
|
bills = append(bills, bill)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +469,7 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string
|
|||||||
bill.ReviewLevel = v
|
bill.ReviewLevel = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bill.OriginalAmount = bill.Amount
|
||||||
bills = append(bills, bill)
|
bills = append(bills, bill)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,3 +551,28 @@ func parseAmount(s string) float64 {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractWechatRefundAmount 从微信"当前状态"字段中提取累计退款金额
|
||||||
|
// 支持格式: "已退款(¥3.00)"、"已退款(¥3.00)"、"已退款¥3.00"
|
||||||
|
func extractWechatRefundAmount(status string) float64 {
|
||||||
|
start := strings.Index(status, "(")
|
||||||
|
end := strings.LastIndex(status, ")")
|
||||||
|
var inner string
|
||||||
|
if start >= 0 && end > start {
|
||||||
|
inner = status[start+1 : end]
|
||||||
|
} else {
|
||||||
|
idx := strings.Index(status, "已退款")
|
||||||
|
if idx < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
inner = status[idx+len("已退款"):]
|
||||||
|
}
|
||||||
|
inner = strings.TrimPrefix(inner, "¥")
|
||||||
|
inner = strings.TrimPrefix(inner, "¥")
|
||||||
|
inner = strings.TrimSpace(inner)
|
||||||
|
v, err := strconv.ParseFloat(inner, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
128
server/service/changelog.go
Normal file
128
server/service/changelog.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChangelogEntry 变更日志条目
|
||||||
|
type ChangelogEntry struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Changes map[string][]string `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseChangelog 解析 CHANGELOG.md 文件
|
||||||
|
func ParseChangelog() ([]ChangelogEntry, error) {
|
||||||
|
// 获取项目根目录
|
||||||
|
rootDir := os.Getenv("PROJECT_ROOT")
|
||||||
|
if rootDir == "" {
|
||||||
|
// 使用二进制文件所在目录作为基准
|
||||||
|
execPath, err := os.Executable()
|
||||||
|
if err == nil {
|
||||||
|
rootDir = filepath.Dir(execPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changelogPath := filepath.Join(rootDir, "CHANGELOG.md")
|
||||||
|
|
||||||
|
file, err := os.Open(changelogPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开 CHANGELOG.md 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var entries []ChangelogEntry
|
||||||
|
var currentEntry *ChangelogEntry
|
||||||
|
var currentCategory string
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
|
||||||
|
// 匹配版本号行 ## [1.4.0] - 2026-03-23
|
||||||
|
if strings.HasPrefix(line, "## [") && strings.Contains(line, "]") {
|
||||||
|
// 保存前一个 entry
|
||||||
|
if currentEntry != nil {
|
||||||
|
entries = append(entries, *currentEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析版本号和日期
|
||||||
|
version, date := parseVersionLine(line)
|
||||||
|
if version != "" && version != "Unreleased" {
|
||||||
|
currentEntry = &ChangelogEntry{
|
||||||
|
Version: version,
|
||||||
|
Date: date,
|
||||||
|
Changes: make(map[string][]string),
|
||||||
|
}
|
||||||
|
currentCategory = ""
|
||||||
|
} else {
|
||||||
|
currentEntry = nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过 Unreleased 和其他非版本行
|
||||||
|
if currentEntry == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配分类行 ### 新增、### 优化等
|
||||||
|
if strings.HasPrefix(line, "### ") {
|
||||||
|
currentCategory = strings.TrimPrefix(line, "### ")
|
||||||
|
if currentEntry.Changes[currentCategory] == nil {
|
||||||
|
currentEntry.Changes[currentCategory] = []string{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配项目行 - 项目描述
|
||||||
|
if strings.HasPrefix(line, "- ") && currentCategory != "" {
|
||||||
|
item := strings.TrimPrefix(line, "- ")
|
||||||
|
// 移除加粗标记和链接等
|
||||||
|
item = cleanItem(item)
|
||||||
|
currentEntry.Changes[currentCategory] = append(currentEntry.Changes[currentCategory], item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存最后一个 entry
|
||||||
|
if currentEntry != nil {
|
||||||
|
entries = append(entries, *currentEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("扫描文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVersionLine 解析版本行 ## [1.4.0] - 2026-03-23
|
||||||
|
func parseVersionLine(line string) (version, date string) {
|
||||||
|
// 提取版本号
|
||||||
|
startIdx := strings.Index(line, "[")
|
||||||
|
endIdx := strings.Index(line, "]")
|
||||||
|
if startIdx >= 0 && endIdx > startIdx {
|
||||||
|
version = line[startIdx+1 : endIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取日期
|
||||||
|
dateStartIdx := strings.LastIndex(line, "- ") + 2
|
||||||
|
if dateStartIdx > 1 {
|
||||||
|
date = strings.TrimSpace(line[dateStartIdx:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanItem 清理项目描述(移除加粗标记等)
|
||||||
|
func cleanItem(item string) string {
|
||||||
|
// 移除加粗标记 **text**
|
||||||
|
item = strings.ReplaceAll(item, "**", "")
|
||||||
|
// 移除代码标记 `text`
|
||||||
|
item = strings.ReplaceAll(item, "`", "")
|
||||||
|
return strings.TrimSpace(item)
|
||||||
|
}
|
||||||
38
server/service/changelog_test.go
Normal file
38
server/service/changelog_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseChangelog(t *testing.T) {
|
||||||
|
// 设置项目根目录
|
||||||
|
os.Setenv("PROJECT_ROOT", "../..")
|
||||||
|
|
||||||
|
changelog, err := ParseChangelog()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseChangelog failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changelog) == 0 {
|
||||||
|
t.Fatal("No changelog entries parsed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证第一个条目(应该是 1.4.0)
|
||||||
|
firstEntry := changelog[0]
|
||||||
|
t.Logf("First entry: v%s - %s", firstEntry.Version, firstEntry.Date)
|
||||||
|
|
||||||
|
if firstEntry.Version != "1.4.0" {
|
||||||
|
t.Errorf("Expected first version to be 1.4.0, got %s", firstEntry.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(firstEntry.Changes) == 0 {
|
||||||
|
t.Error("First entry has no changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印所有版本
|
||||||
|
t.Logf("Total versions: %d", len(changelog))
|
||||||
|
for _, entry := range changelog {
|
||||||
|
t.Logf(" - v%s: %d categories", entry.Version, len(entry.Changes))
|
||||||
|
}
|
||||||
|
}
|
||||||
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.3.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 {};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { UIBill } from '$lib/models/bill';
|
|||||||
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
|
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
|
||||||
const API_BASE = '';
|
const API_BASE = '';
|
||||||
|
|
||||||
async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
|
export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@@ -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`);
|
||||||
@@ -327,6 +316,46 @@ export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsRe
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出账单为 Excel
|
||||||
|
export async function exportBills(params: FetchBillsParams = {}): Promise<void> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params.start_date) searchParams.set('start_date', params.start_date);
|
||||||
|
if (params.end_date) searchParams.set('end_date', params.end_date);
|
||||||
|
if (params.category) searchParams.set('category', params.category);
|
||||||
|
if (params.type) searchParams.set('type', params.type);
|
||||||
|
if (params.income_expense) searchParams.set('income_expense', params.income_expense);
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${API_BASE}/api/bills/export${queryString ? '?' + queryString : ''}`;
|
||||||
|
|
||||||
|
const response = await apiFetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = `bills_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
filename = match[1].replace(/['"]/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// 手动输入账单数据
|
// 手动输入账单数据
|
||||||
export interface ManualBillInput {
|
export interface ManualBillInput {
|
||||||
time: string;
|
time: string;
|
||||||
@@ -403,8 +432,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) {
|
||||||
|
|||||||
114
web/src/lib/components/ChangelogDrawer.svelte
Normal file
114
web/src/lib/components/ChangelogDrawer.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<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';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiFetch } from '$lib/api';
|
||||||
|
|
||||||
|
let { open = $bindable(false) } = $props();
|
||||||
|
|
||||||
|
interface ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
changes: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changelog = $state<ChangelogEntry[]>([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// 获取更新日志
|
||||||
|
async function fetchChangelog() {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/changelog');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.result && Array.isArray(data.data)) {
|
||||||
|
changelog = data.data;
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to fetch changelog';
|
||||||
|
console.error('Failed to fetch changelog:', err);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时获取数据
|
||||||
|
onMount(() => {
|
||||||
|
fetchChangelog();
|
||||||
|
});
|
||||||
|
</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">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex items-center justify-center py-8">
|
||||||
|
<div class="text-muted-foreground">加载中...</div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center justify-center py-8">
|
||||||
|
<div class="text-destructive text-sm">{error}</div>
|
||||||
|
</div>
|
||||||
|
{:else if changelog.length === 0}
|
||||||
|
<div class="flex items-center justify-center py-8">
|
||||||
|
<div class="text-muted-foreground">暂无更新日志</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet.Footer class="border-t pt-4">
|
||||||
|
<Button variant="outline" onclick={() => (open = false)} class="w-full">关闭</Button>
|
||||||
|
</Sheet.Footer>
|
||||||
|
</Sheet.Content>
|
||||||
|
</Sheet.Root>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
record?: UIBill | null;
|
record?: UIBill | null;
|
||||||
categories?: string[];
|
categories?: readonly string[];
|
||||||
|
|
||||||
title?: string;
|
title?: string;
|
||||||
viewDescription?: string;
|
viewDescription?: string;
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
|
<div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : record.incomeExpense === '不计收支' ? 'text-muted-foreground' : 'text-red-600 dark:text-red-400'}">
|
||||||
¥{record.amount.toFixed(2)}
|
¥{record.amount.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>
|
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -176,6 +199,20 @@
|
|||||||
dayData.set(category, (dayData.get(category) || 0) + amount);
|
dayData.set(category, (dayData.get(category) || 0) + amount);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 填充缺失的日期(零支出日期)
|
||||||
|
const now = new Date();
|
||||||
|
const allDates: string[] = [];
|
||||||
|
const cursor = new Date(cutoffDate);
|
||||||
|
while (cursor <= now) {
|
||||||
|
allDates.push(formatLocalDate(cursor));
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
allDates.forEach(dateStr => {
|
||||||
|
if (!dailyMap.has(dateStr)) {
|
||||||
|
dailyMap.set(dateStr, new Map());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const dayCount = dailyMap.size;
|
const dayCount = dailyMap.size;
|
||||||
|
|
||||||
// 根据天数决定聚合粒度
|
// 根据天数决定聚合粒度
|
||||||
@@ -923,6 +960,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}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const categories = [
|
|||||||
'服饰鞋包',
|
'服饰鞋包',
|
||||||
'宠物相关',
|
'宠物相关',
|
||||||
'住房物业',
|
'住房物业',
|
||||||
|
'酒店旅游',
|
||||||
'退款',
|
'退款',
|
||||||
'工资收入',
|
'工资收入',
|
||||||
'投资理财',
|
'投资理财',
|
||||||
@@ -40,6 +41,7 @@ export const expenseCategories = [
|
|||||||
'服饰鞋包',
|
'服饰鞋包',
|
||||||
'宠物相关',
|
'宠物相关',
|
||||||
'住房物业',
|
'住房物业',
|
||||||
|
'酒店旅游',
|
||||||
'其他支出',
|
'其他支出',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>();
|
||||||
@@ -209,6 +235,8 @@
|
|||||||
});
|
});
|
||||||
if (response.result && response.data) {
|
if (response.result && response.data) {
|
||||||
records = response.data.bills || [];
|
records = response.data.bills || [];
|
||||||
|
backendTotalExpense = response.data.total_expense || 0;
|
||||||
|
backendTotalIncome = response.data.total_income || 0;
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
errorMessage = '暂无账单数据';
|
errorMessage = '暂无账单数据';
|
||||||
}
|
}
|
||||||
@@ -289,7 +317,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 +340,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>
|
||||||
|
|||||||
@@ -14,8 +14,10 @@
|
|||||||
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
|
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
|
||||||
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 { formatLocalDate, formatDateTime } from '$lib/utils';
|
import { categories } from '$lib/data/categories';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import { formatLocalDate, formatDateTime } from '$lib/utils';
|
||||||
|
import { exportBills } from '$lib/api';
|
||||||
|
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 Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import Receipt from '@lucide/svelte/icons/receipt';
|
import Receipt from '@lucide/svelte/icons/receipt';
|
||||||
@@ -25,9 +27,10 @@
|
|||||||
import Filter from '@lucide/svelte/icons/filter';
|
import Filter from '@lucide/svelte/icons/filter';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import List from '@lucide/svelte/icons/list';
|
import List from '@lucide/svelte/icons/list';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
@@ -89,12 +92,7 @@
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分类列表(硬编码常用分类)
|
|
||||||
const categories = [
|
|
||||||
'餐饮美食', '交通出行', '生活服务', '日用百货',
|
|
||||||
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
|
|
||||||
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
|
|
||||||
];
|
|
||||||
|
|
||||||
async function loadBills() {
|
async function loadBills() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -225,6 +223,29 @@
|
|||||||
totalIncome = Math.max(0, totalIncome - deleted.amount);
|
totalIncome = Math.max(0, totalIncome - deleted.amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出 Excel
|
||||||
|
let isExporting = $state(false);
|
||||||
|
let exportError = $state('');
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
isExporting = true;
|
||||||
|
exportError = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportBills({
|
||||||
|
start_date: startDate || undefined,
|
||||||
|
end_date: endDate || undefined,
|
||||||
|
category: filterCategory || undefined,
|
||||||
|
type: filterBillType || undefined,
|
||||||
|
income_expense: filterIncomeExpense || undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
exportError = err instanceof Error ? err.message : '导出失败';
|
||||||
|
} finally {
|
||||||
|
isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -244,6 +265,10 @@
|
|||||||
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
|
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" onclick={handleExport} disabled={isExporting || totalRecords === 0}>
|
||||||
|
<Download class="mr-2 h-4 w-4 {isExporting ? 'animate-spin' : ''}" />
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
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 { updateBill } from '$lib/api';
|
||||||
|
import { categories } from '$lib/data/categories';
|
||||||
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';
|
||||||
@@ -80,12 +81,7 @@
|
|||||||
let drawerOpen = $state(false);
|
let drawerOpen = $state(false);
|
||||||
let selectedBill = $state<UIBill | null>(null);
|
let selectedBill = $state<UIBill | null>(null);
|
||||||
|
|
||||||
// 分类列表(用于编辑选择)
|
|
||||||
const categories = [
|
|
||||||
'餐饮美食', '交通出行', '生活服务', '日用百货',
|
|
||||||
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
|
|
||||||
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
|
|
||||||
];
|
|
||||||
|
|
||||||
// 点击行打开详情
|
// 点击行打开详情
|
||||||
function handleRowClick(record: CleanedBill) {
|
function handleRowClick(record: CleanedBill) {
|
||||||
|
|||||||
@@ -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