Compare commits

...

36 Commits

Author SHA1 Message Date
a2de8c5078 feat: implement WeChat cross-batch refund reconciliation and fix misc issues
WeChat cross-batch refund reconciliation:
- Add OriginalAmount field to CleanedBill for accurate cumulative refund math
- DeduplicateRawFile detects WeChat status-update rows (已退款/已全额退款) and
  emits WechatRefundUpdates for Go-side reconciliation (Scenario 1)
- WechatPy cleaner surfaces -退款 income rows with no same-batch expense match
  as unresolved_refunds for Go ReconcileRefund (Scenario 2)
- Add ReconcileWechatRefund to repository interface and MongoDB implementation
- upload.go step 15 iterates WechatRefundUpdates and reconciles against bills_cleaned

Bug fixes:
- ReviewStats: add nil repo check to prevent panic when DB is not connected
- JWT: remove hardcoded fallback secret; return 500/401 if JWTSecret not configured
- Remove unused parsePageParam dead code and its strconv import
- BillDetailDrawer: show 不计收支 amount in muted gray instead of red
- test_jd_cleaner.py: replace hardcoded D:\Projects\BillAI path with dynamic __file__ resolution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:38:25 +08:00
e2e1beb6f7 feat: implement cross-batch Alipay refund reconciliation
When a refund row in an uploaded Alipay bill has no matching expense
row in the same batch (because the original purchase was uploaded in a
prior batch), the refund is now reconciled against the stored record in
bills_cleaned rather than being silently discarded.

Changes:
- analyzer/cleaners/base.py: add unresolved_refunds list to BaseCleaner
- analyzer/cleaners/alipay.py: _aggregate_refunds stores full refund
  metadata (dict); _process_expenses tracks matched keys and populates
  self.unresolved_refunds for unmatched refunds
- analyzer/server.py: thread unresolved_refunds through do_clean,
  CleanResponse, and both /clean endpoints
- server/adapter/adapter.go: add UnresolvedRefund type and field to CleanResult
- server/adapter/http/cleaner.go: deserialize unresolved_refunds from
  Python response and populate CleanResult
- server/repository/repository.go: add ReconcileRefund to BillRepository interface
- server/repository/mongo/repository.go: implement ReconcileRefund —
  full refund soft-deletes the bill, partial refund reduces amount and
  appends remark with original amount and refund order number
- server/handler/upload.go: capture clean result and call ReconcileRefund
  for each unresolved refund after saving cleaned bills
- server/model/response.go: add ReconciledRefundCount to UploadData

Also: add CLAUDE.md (@AGENTS.md), update AGENTS.md, fix DailyTrendChart
missing-date gap by filling zero-expense dates in daily map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:29:47 +08:00
clz
bb717faac3 fix: resolve CHANGELOG.md path issue for Docker deployment
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
- Update changelog.go to use binary directory as base path
  - Uses os.Executable() instead of relative path '..'
  - Automatically adapts to local and Docker environments

- Modify server Dockerfile build context
  - Change context from ./server to project root (.)
  - Update COPY commands for new context
  - Add CHANGELOG.md to container image

- Update AGENTS.md guidelines
  - Explicitly specify relative path format for file references

This ensures the changelog API works correctly in both local development
and Docker deployment environments.
2026-04-02 20:45:24 +08:00
clz
31932502d0 feat: implement dynamic changelog system and bump version to 1.4.0
- Add /api/changelog public endpoint that dynamically parses CHANGELOG.md
- Implement Markdown parser in server/service/changelog.go with unit tests
- Update ChangelogDrawer component to fetch from API instead of hardcoded data
- Export apiFetch function to support public API endpoints
- Add loading/error/success states to changelog UI
- Update AGENTS.md and CHANGELOG.md to reflect new version and features
2026-04-02 18:28:04 +08:00
clz
ee163e123d feat: implement dynamic changelog loading from API
- Add GET /api/changelog endpoint to fetch changelog from CHANGELOG.md
- Create service/changelog.go to parse CHANGELOG.md markdown file
- Add handler/changelog.go to handle changelog requests
- Update ChangelogDrawer component to fetch from API instead of hardcoded data
- Export apiFetch from lib/api.ts for public use
- Add changelog parser tests with 14 version entries verified
2026-04-02 17:52:38 +08:00
clz
c4d8c2e105 release: bump version to 1.4.0 2026-04-02 17:42:17 +08:00
clz
7caac4d93c docs: add v1.4 to changelog 2026-04-02 17:39:50 +08:00
clz
ac79b4f2ea chore: add server/server build artifact to gitignore 2026-04-02 17:38:41 +08:00
clz
21d73c212a chore: 更新 CHANGELOG.md 为 v1.0.8
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
2026-03-23 19:18:00 +08:00
clz
02de11caac feat: 新增账单导出 Excel 功能
- 后端新增 /api/bills/export 接口,支持当前筛选条件导出全部记录
- 使用 excelize 库生成 xlsx 格式文件
- 前端账单管理页面添加导出按钮
- 更新 Go 版本到 1.24 以支持 excelize 依赖
2026-03-23 19:16:54 +08:00
clz
d813fe4307 fix: 分析页面切换日期后总收支统计不更新
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
onDateChange 函数重新请求数据后同步更新 backendTotalExpense
和 backendTotalIncome,修复切换日期范围后顶部统计卡片
仍显示旧数据的问题。
2026-03-04 12:17:05 +08:00
clz
50ae47588a fix: 修复分类名称不一致问题
- category.yaml 中「宠物用品」改为「宠物相关」,与前端 categories.ts 保持一致
- 删除「转账红包」独立分类,此类流水归入默认的「其他支出」
2026-03-04 12:16:59 +08:00
clz
5f10173ffb docs: 更新 CHANGELOG.md 2026-03-04 00:46:23 +08:00
clz
7022e46793 docs: 更新 AGENTS.md
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
补充 Svelte 5 runes、SvelteKit 代理架构、Go LocalTime 类型、
软删除约束、Adapter 模式、账单去重策略等细节,
扩充重要文件列表,完善测试命令说明。
2026-03-03 20:50:59 +08:00
clz
f68cd2f6ea feat: 新增酒店旅游支出分类
categories.ts 的 categories 和 expenseCategories 均加入「酒店旅游」。
category.yaml 新增酒店旅游分类关键词(酒店、民宿、携程、旅游、景区等),
并将相关词从文化休闲和交通出行中移除,避免分类冲突。
2026-03-03 20:50:52 +08:00
clz
aa4f1615ce fix: 统一各页面账单分类来源
bills 和 review 页面改从 $lib/data/categories 导入分类列表,
删除本地重复硬编码的旧版 13 项分类。
BillDetailDrawer 的 categories prop 类型改为 readonly string[]
以兼容 as const 导出的元组类型。
2026-03-03 20:50:45 +08:00
clz
642ea2d3ef fix: 修复账单删除功能并支持分析页面删除操作
Some checks failed
Deploy BillAI / Deploy to Production (push) Has been cancelled
- 将删除接口从 DELETE /api/bills/:id 改为 POST /api/bills/:id/delete 以兼容 SvelteKit 代理
- 分析页面组件 (TopExpenses/BillRecordsTable/DailyTrendChart) 支持删除并同步更新统计数据
- Review 接口改为直接查询 MongoDB 而非读取文件
- 软删除时记录 updated_at 时间戳
- 添加 .dockerignore 文件优化构建
- 完善 AGENTS.md 文档
2026-02-16 22:28:49 +08:00
CHE LIANG ZHAO
a5f1a370c7 feat: 改用 Docker 模式运行 Gitea Actions
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 26s
2026-01-26 18:30:28 +08:00
CHE LIANG ZHAO
b7399d185f 不要reset
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 18:20:49 +08:00
CHE LIANG ZHAO
5537e1234d fix: 挂载 Docker CLI 插件目录以支持 docker compose 命令
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 18:14:14 +08:00
CHE LIANG ZHAO
f6437b2ada fix: 挂载 docker 和 docker-compose 命令到 Runner 容器
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 18:10:38 +08:00
CHE LIANG ZHAO
cc0623c15a fix: 添加 git safe.directory 配置解决权限问题
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 1s
2026-01-26 18:06:38 +08:00
CHE LIANG ZHAO
cb4273fad0 chore: 恢复 runner 标签为 ubuntu-latest
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 18:05:00 +08:00
CHE LIANG ZHAO
99ec5ea0a4 chore: workflow 添加部署路径日志输出
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 17:56:30 +08:00
CHE LIANG ZHAO
89e1e74b76 chore: 更新 runner 标签为 billAI
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 17:27:24 +08:00
CHE LIANG ZHAO
ed0a44851d feat: 添加 Gitea Actions 自动部署功能
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 17:21:12 +08:00
CHE LIANG ZHAO
a1eebd0b3f fix: 优化版本号显示样式 (v1.3.1) 2026-01-26 16:37:00 +08:00
CHE LIANG ZHAO
ef34a1bb5d fix: 修复 Vite allow list 错误 2026-01-26 16:24:39 +08:00
CHE LIANG ZHAO
ab9aab7beb fix: 修复版本号导入 Vite serving allow list 错误 2026-01-26 16:22:27 +08:00
CHE LIANG ZHAO
61d26fc971 feat: 在 web 页面显示版本号和更新日志 2026-01-26 16:06:06 +08:00
CHE LIANG ZHAO
f537b53ebd chore: release v1.3.0 - 京东账单支持 2026-01-26 15:36:05 +08:00
CHE LIANG ZHAO
b654265d96 docs: add gitea webhook deploy plan 2026-01-26 15:25:23 +08:00
CHE LIANG ZHAO
42171c01db feat: 京东账单支持 LOW 复核等级 2026-01-26 14:25:39 +08:00
CHE LIANG ZHAO
279eceaa95 feat: 京东账单专属分类映射配置 2026-01-26 14:14:07 +08:00
CHE LIANG ZHAO
9e146c5ef0 feat: 上传京东账单时自动软删除其他来源中的京东订单 2026-01-26 14:11:55 +08:00
CHE LIANG ZHAO
3cf39b4664 feat: 支持京东白条账单上传和清洗 2026-01-26 13:44:22 +08:00
67 changed files with 2498 additions and 321 deletions

View 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
View File

@@ -8,6 +8,7 @@ analyzer/venv/
# Go
server/billai-server
server/server
.exe
# IDE

221
AGENTS.md
View File

@@ -2,140 +2,153 @@
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
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript
- `server/` - Go 1.21 + Gin + MongoDB
- `analyzer/` - Python 3.12 + FastAPI
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend, port 3000)
- `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
### Frontend (web/)
### Frontend (`web/`)
```bash
npm run dev # Start dev server
npm run build # Production build
npm run check # TypeScript check
npm run lint # Prettier + ESLint
npm run format # Format code
npm run test # Run all tests
npx vitest run src/routes/+page.spec.ts # Single test file
npx vitest run -t "test name" # Test by name
npm run dev # Start dev server
npm run build # Production build
npm run check # TypeScript + Svelte check
npm run lint # Prettier --check + ESLint
npm run format # Prettier --write
npm run test # vitest --run (CI mode)
npx vitest run src/xxx.spec.ts # Run single test file
```
### Backend (server/)
**Note:** `web/` has a `yarn.lock` but scripts in `package.json` use `npm run`.
### Backend (`server/`)
```bash
go run . # Start server
go build . # Build binary
go mod tidy # Clean dependencies
go test ./... # All tests
go test ./handler/... # Package tests
go test -run TestName # Single test
go run . # Start server (reads server/config.yaml)
go build -o server .
go test ./... # Run all tests
go test -run TestName ./... # Run single test
go test -v ./handler/... # Verbose output
```
### Analyzer (analyzer/)
### Analyzer (`analyzer/`)
```bash
python server.py # Start FastAPI server
pytest # All tests
pytest test_file.py # Single file
pytest -k "test_name" # Test by pattern
python server.py # Start FastAPI (has `if __name__ == "__main__"`)
uvicorn server:app --reload # Requires cwd == analyzer/
pytest # Run all tests
pytest test_jd_cleaner.py # Single test file
```
### Docker
```bash
docker-compose up -d --build # Start/rebuild all
docker-compose logs -f server # Follow service logs
docker compose up -d --build --remove-orphans # Start/rebuild all
docker compose logs -f server # Follow logs
docker compose down # Stop services
```
## Code Style
## Code Style & Conventions
### TypeScript/Svelte
**Prettier config:** Tabs, single quotes, no trailing commas, width 100
### TypeScript/Svelte (`web/`)
- **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:**
```typescript
import { browser } from '$app/environment'; // SvelteKit
import { auth } from '$lib/stores/auth'; // Internal
import type { UIBill } from '$lib/models/bill';
### Go (`server/`)
- **Module:** `billai-server` (import path). Use this in `go test` / `go build` when outside the directory.
- **Layer:** `handler``service``adapter`/`repository``model`. No business logic in handlers.
- **Struct tags:** JSON `snake_case`, `omitempty` for optional. Pointer types for optional patch fields. Sensitive fields: `json:"-"`.
- **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:**
```typescript
export interface UploadResponse {
result: boolean;
message: string;
data?: UploadData;
}
```
### Authentication
- JWT (HS256). Header: `Authorization: Bearer <token>`.
- `middleware.AuthRequired()` guards authed routes. Public routes: `/api/auth/*`, `/api/changelog`, `/health`.
- Frontend `apiFetch()` intercepts 401 → `auth.logout()` + redirect `/login`.
**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:**
```typescript
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Handle 401 -> logout redirect
```
### Cross-Batch Refund Reconciliation
When the original purchase and its refund are in different upload batches, within-batch logic can't match them. Two reconciliation paths handle this:
### Go Backend
**Structure:** `handler/``service/``repository/` → MongoDB
**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.
**JSON tags:** snake_case, omitempty for optional fields
```go
type UpdateBillRequest struct {
Category *string `json:"category,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
```
**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`.
**Response format:**
```go
type Response struct {
Result bool `json:"result"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
}
```
### Adapter (Go ↔ Python)
`adapter.Cleaner` interface. Two modes:
- `http` (default): calls FastAPI at `ANALYZER_URL`
- `subprocess`: spawns `python analyzer/clean_bill.py`
**Error handling:**
```go
if err == repository.ErrNotFound {
c.JSON(http.StatusNotFound, Response{Result: false, Message: "not found"})
return
}
```
Set via `ANALYZER_MODE` env var or `server/config.yaml` `analyzer.mode`.
### Python Analyzer
**Style:** PEP 8, type hints, Pydantic models
### Config Precedence
Go backend reads `server/config.yaml`, but Docker compose sets env vars (`ANALYZER_URL`, `MONGO_URI`, `JWT_SECRET`, etc.) that override it.
```python
def do_clean(
input_path: str,
output_path: str,
bill_type: str = "auto"
) -> tuple[bool, str, str]:
```
### SvelteKit Config Notes
- `svelte.config.js` uses `adapter-node` for Docker SSR.
- `csrf.trustedOrigins: ['*']` disables CSRF checks.
- `onwarn` ignores all `a11y_*` warnings (chart components).
**Error handling:**
```python
if not success:
raise HTTPException(status_code=400, detail=message)
```
### Deployment
- Gitea Actions self-hosted runner (`.gitea/workflows/deploy.yaml`), not GitHub.
- `deploy.sh` is the manual deployment script (same logic as CI).
## Key Patterns
**API Flow:** Frontend (SvelteKit proxy) → Go API → MongoDB + Python analyzer
**Auth:** JWT tokens, Bearer header, 401 → logout redirect
**File Processing:** ZIP → extract → convert (GBK→UTF-8, xlsx→csv) → clean → import
**Testing:** Vitest + Playwright for frontend, Go test for backend
### Test Coverage
Sparse. Existing tests:
- `web/src/demo.spec.ts` / `page.svelte.spec.ts`
- `server/service/changelog_test.go`
- `analyzer/test_jd_cleaner.py`
## Important Files
- `web/src/lib/api.ts` - API client
- `web/src/lib/models/` - UI data models
- `server/handler/` - HTTP handlers
- `server/service/` - Business logic
- `server/model/` - Go data structures
- `analyzer/cleaners/` - Bill processing
- `mock_data/*.zip` - Test data (password: 123456)
| File | Role |
|---|---|
| `web/src/lib/api.ts` | Central API client, auth injection, all API functions |
| `web/src/lib/stores/auth.ts` | Auth state, JWT handling, localStorage key `auth` |
| `web/src/lib/models/bill.ts` | UIBill model + snake_case ↔ camelCase converters |
| `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.

View File

@@ -5,6 +5,98 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)
版本号遵循 [语义化版本](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
### 新增
- **京东账单支持** - 支持京东白条账单上传和清洗
- 自动识别京东账单类型(交易流水 ZIP
- 解析京东白条账单 CSV 格式(含还款日期信息)
- 京东专属分类映射配置(`config/category_jd.yaml`
- 支持京东外卖、京东平台商户等商户识别
- 上传页面和账单列表页面添加"京东"选项
- 账单来源 Badge 添加紫色京东标识
### 优化
- **京东订单智能去重** - 上传京东账单时自动软删除其他来源中的京东订单
- 识别描述中包含"京东-订单编号"的支付宝/微信账单
- 软删除冲突记录,避免重复计入支出
- 上传响应返回被删除的记录数
- **分类推断复核等级优化** - 京东账单引入 LOW 复核等级
- 商户映射成功(如"京东外卖"):无需复核
- 原分类映射成功(如"食品酒饮"→餐饮美食):无需复核
- 通用关键词匹配成功:**LOW 复核**(需确认推断准确性)
- 未知分类或匹配失败HIGH 复核
- **京东平台商户关键词扩展** - 在通用分类配置中添加京东平台常见关键词
- 宠物用品:小佩、米家宠物、猫砂、猫粮等
- 数码电器:小米、延长保修、家电等
### 技术改进
- 新增 `analyzer/cleaners/jd.py` 京东账单清理器
- 新增 `analyzer/config/category_jd.yaml` 京东专属配置
- 后端新增 `SoftDeleteJDRelatedBills()` 接口和实现
- 前端 API 类型添加 `'jd'` 支持
- 新增单元测试 `analyzer/test_jd_cleaner.py`11 个测试用例)
### 文档
- 更新 `TODO.md` 添加 Gitea Webhook 自动部署计划
## [1.2.0] - 2026-01-25
### 新增

5
CLAUDE.md Normal file
View 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

20
TODO.md
View File

@@ -34,6 +34,26 @@
### 高优先级
- [ ] **Gitea Webhook 自动部署**
- Webhook 服务Go 实现)
- 监听端口 9000接收 Gitea POST 请求
- HMAC-SHA256 签名验证
- 仅处理 master/main 分支的 push 事件
- 执行部署脚本
- 部署脚本 (deploy.sh)
- `git pull origin master`
- `docker-compose up -d --build --remove-orphans`
- 自动清理旧镜像
- 健康检查验证
- docker-compose 配置
- webhook 服务定义
- 挂载 docker.sock 和项目目录
- 环境变量配置WEBHOOK_SECRET
- Gitea 仓库配置
- 添加 Webhook URL: `http://服务器IP:9000/webhook`
- 设置 Secret与服务端一致
- 选择 Push 事件,分支过滤 `refs/heads/master`
- [ ] **SSE 实时状态推送**
- 服务器实现 `/events` SSE 端点
- 前端使用 EventSource 接收状态

13
analyzer/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*$py.class
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.git
.gitignore
.DS_Store

View File

@@ -0,0 +1,40 @@
"""分析京东账单数据"""
import json
import sys
sys.stdout.reconfigure(encoding='utf-8')
with open('../jd_bills.json', 'r', encoding='utf-8') as f:
d = json.load(f)
bills = [b for b in d['data']['bills'] if b['bill_type'] == 'jd']
print(f'Total JD bills: {len(bills)}')
print()
# Review level distribution
review_levels = {}
for b in bills:
lvl = b['review_level'] or 'NONE'
review_levels[lvl] = review_levels.get(lvl, 0) + 1
print('Review level distribution:')
for lvl, cnt in sorted(review_levels.items()):
print(f' {lvl}: {cnt}')
print()
# Category distribution
categories = {}
for b in bills:
cat = b['category']
categories[cat] = categories.get(cat, 0) + 1
print('Category distribution:')
for cat, cnt in sorted(categories.items(), key=lambda x: -x[1]):
print(f' {cat}: {cnt}')
print()
# Show bills that need review
print('Bills needing review:')
print(f"{'Level':<5} | {'Category':<12} | {'Merchant':<20} | Description")
print('-' * 70)
for b in bills:
if b['review_level']:
print(f"{b['review_level']:<5} | {b['category']:<12} | {b['merchant'][:20]:<20} | {b['description'][:30]}")

View File

@@ -4,6 +4,7 @@
from .base import BaseCleaner
from .alipay import AlipayCleaner
from .wechat import WechatCleaner
from .jd import JDCleaner
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner']
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner', 'JDCleaner']

View File

@@ -100,45 +100,54 @@ class AlipayCleaner(BaseCleaner):
def _aggregate_refunds(self, refund_rows: list) -> dict:
"""聚合退款金额"""
order_refunds = {}
for row in refund_rows:
if len(row) >= 11:
refund_order_no = row[9].strip()
refund_merchant_no = row[10].strip()
refund_amount = parse_amount(row[6])
original_order = refund_order_no.split("_")[0] if "_" in refund_order_no else refund_order_no
key = original_order if original_order else refund_merchant_no
if key:
if key not in order_refunds:
order_refunds[key] = Decimal("0")
order_refunds[key] += refund_amount
order_refunds[key] = {
"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}")
return order_refunds
def _process_expenses(self, expense_rows: list, order_refunds: dict) -> list:
"""处理支出记录"""
final_rows = []
matched_keys = set()
for row in expense_rows:
if len(row) >= 12:
order_no = row[9].strip()
merchant_no = row[10].strip()
expense_amount = parse_amount(row[6])
# 查找对应的退款
refund_amount = Decimal("0")
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)):
refund_amount = amount
refund_amount = refund["amount"]
matched_key = key
break
if matched_key:
matched_keys.add(matched_key)
if refund_amount >= expense_amount:
# 全额退款,删除
self.stats["fully_refunded"] += 1
@@ -148,10 +157,10 @@ class AlipayCleaner(BaseCleaner):
remaining = expense_amount - refund_amount
new_row = row.copy()
new_row[6] = format_amount(remaining)
original_remark = new_row[11] if len(new_row) > 11 else ""
new_row[11] = f"原金额{expense_amount}元,退款{refund_amount}{';' + original_remark if original_remark else ''}"
final_rows.append(new_row)
self.stats["partially_refunded"] += 1
print(f" 部分退款: {row[0]} | {row[2]} | 原{expense_amount}元 -> {format_amount(remaining)}")
@@ -163,7 +172,22 @@ class AlipayCleaner(BaseCleaner):
self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1
else:
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
def _is_platform_merchant(self, merchant: str) -> bool:

View File

@@ -220,6 +220,9 @@ class BaseCleaner(ABC):
"category_adjusted": 0,
"final_count": 0,
}
# 本次清理中未能在同批次内匹配到对应支出的退款(跨批次核销用)
self.unresolved_refunds: list[dict] = []
def set_date_range(self, start_date: date | None, end_date: date | None):
"""设置日期筛选范围"""

370
analyzer/cleaners/jd.py Normal file
View File

@@ -0,0 +1,370 @@
"""
京东白条账单清理模块
"""
import csv
import re
from decimal import Decimal
from pathlib import Path
import yaml
from .base import (
BaseCleaner, parse_amount, format_amount,
is_in_date_range, create_arg_parser
)
from category import infer_category
# 加载京东专属分类配置
JD_CONFIG_FILE = Path(__file__).parent.parent / "config" / "category_jd.yaml"
def load_jd_config():
"""加载京东分类配置"""
with open(JD_CONFIG_FILE, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
_jd_config = load_jd_config()
def infer_jd_category(merchant: str, product: str, original_category: str) -> tuple[str, bool, int]:
"""
根据京东账单的商户名称、商品说明和原分类推断统一分类
Args:
merchant: 商户名称(如"京东外卖""京东平台商户"
product: 交易说明/商品说明
original_category: 京东原始分类(如"食品酒饮""数码电器"
Returns:
(分类名称, 是否确定, 复核等级)
复核等级:
0 = 无需复核(商户映射或原分类映射成功,高置信度)
1 = 低优先级复核(通用关键词匹配成功,需确认)
2 = 高优先级复核(全部匹配失败或未知分类,需人工分类)
"""
# 1. 先检查商户名称直接映射(如"京东外卖" -> "餐饮美食"
merchant_mapping = _jd_config.get("商户映射", {})
for merchant_key, category in merchant_mapping.items():
if merchant_key in merchant:
return category, True, 0 # 商户映射,无需复核
# 2. 尝试直接映射京东原分类
category_mapping = _jd_config.get("分类映射", {})
# 处理多分类情况(如"食品酒饮 其他网购"
original_cats = original_category.split() if original_category else []
for orig_cat in original_cats:
if orig_cat in category_mapping:
mapped = category_mapping[orig_cat]
if mapped: # 非空映射 → 使用映射结果
return mapped, True, 0 # 原分类映射,无需复核
# 空映射(如"其他"→"")→ 继续检查下一个原分类或进入关键词匹配
else:
# 未知分类(不在映射表中)→ 保留原分类HIGH 复核
return orig_cat, True, 2
# 3. 使用通用分类推断(已包含京东平台商户关键词)
category, is_certain = infer_category(merchant, product, "支出")
if is_certain:
return category, True, 1 # 关键词匹配,低优先级复核
# 4. 返回默认分类
return _jd_config.get("默认分类", "其他支出"), False, 2 # 全部失败,高优先级复核
# 与支付宝/微信对齐的表头(包含"复核等级"字段)
ALIGNED_HEADER = [
"交易时间", "交易分类", "交易对方", "对方账号", "商品说明",
"收/支", "金额", "收/付款方式", "交易状态", "交易订单号", "商家订单号", "备注", "复核等级"
]
class JDCleaner(BaseCleaner):
"""京东白条账单清理器"""
def clean(self) -> None:
"""执行清理"""
self.print_header()
# 读取数据,跳过京东导出文件的头部信息
with open(self.input_file, "r", encoding="utf-8") as f:
reader = csv.reader(f)
header = None
rows = []
for row in reader:
# 跳过空行
if not row or not row[0].strip():
continue
# 清理每个字段的 tab 字符
row = [cell.strip().replace('\t', '') for cell in row]
# 查找实际的CSV头部行包含"交易时间"和"商户名称"
if header is None:
if len(row) >= 2 and "交易时间" in row[0] and "商户名称" in row[1]:
header = row
continue
# 跳过头部信息行
continue
# 收集数据行
rows.append(row)
# 确保找到了有效的头部
if header is None:
raise ValueError("无法找到有效的京东账单表头(需包含'交易时间''商户名称'列)")
self.stats["original_count"] = len(rows)
print(f"原始数据行数: {len(rows)}")
# 第一步:按日期范围筛选
rows_filtered = [
row for row in rows
if row and is_in_date_range(row[0], self.start_date, self.end_date)
]
self.stats["filtered_count"] = len(rows_filtered)
date_desc = f"{self.start_date} ~ {self.end_date}" if self.start_date or self.end_date else "全部"
print(f"筛选后数据行数: {len(rows_filtered)} ({date_desc})")
# 第二步:分离退款和支出条目(过滤掉"不计收支"
refund_rows = []
expense_rows = []
skipped_count = 0 # 不计收支(还款、冻结等)
for row in rows_filtered:
if len(row) < 7:
continue
income_expense = row[6].strip() # 收/支 列
transaction_desc = row[2].strip() # 交易说明
status = row[5].strip() # 交易状态
# 过滤掉"不计收支"记录(还款、冻结、预授权等)
if income_expense == "不计收支":
skipped_count += 1
continue
# 退款判断:交易说明以"退款-"开头 或 状态包含"退款成功"
if transaction_desc.startswith("退款-") or "退款" in status:
refund_rows.append(row)
elif income_expense == "支出":
expense_rows.append(row)
print(f"退款条目数: {len(refund_rows)}")
print(f"支出条目数: {len(expense_rows)}")
print(f"不计收支过滤: {skipped_count} 条(还款/冻结等)")
# 第三步:处理退款
# 京东账单特点:已全额退款的记录金额会显示为 "179.00(已全额退款)"
final_expense_rows = self._process_expenses(expense_rows, refund_rows)
print(f"\n处理结果:")
print(f" 全额退款删除: {self.stats['fully_refunded']}")
print(f" 部分退款调整: {self.stats['partially_refunded']}")
if self.stats.get("zero_amount", 0) > 0:
print(f" 0元记录过滤: {self.stats['zero_amount']}")
print(f" 最终保留行数: {len(final_expense_rows)}")
# 第四步:转换为对齐格式并重新分类
aligned_rows = [self._convert_and_reclassify(row_data) for row_data in final_expense_rows]
# 按时间排序(最新在前)
aligned_rows.sort(key=lambda x: x[0], reverse=True)
# 统计复核数量
review_high_count = sum(1 for row in aligned_rows if row[-1] == "HIGH")
self.stats["final_count"] = len(aligned_rows)
if review_high_count > 0:
print(f" 高优先级复核: {review_high_count} 条(无法判断)")
# 写入文件
self.write_output(ALIGNED_HEADER, aligned_rows)
print(f"\n清理后的数据已保存到: {self.output_file}")
# 统计支出
self._print_expense_summary(aligned_rows)
def _parse_jd_amount(self, amount_str: str) -> tuple[Decimal, bool]:
"""
解析京东账单金额
京东金额格式特点:
- 普通金额: "179.00"
- 全额退款: "179.00(已全额退款)"
Returns:
(金额, 是否已全额退款)
"""
amount_str = amount_str.strip()
# 检查是否包含"已全额退款"
if "(已全额退款)" in amount_str or "(已全额退款)" in amount_str:
# 提取金额部分
amount_part = re.sub(r'[(]已全额退款[)]', '', amount_str)
return parse_amount(amount_part), True
return parse_amount(amount_str), False
def _process_expenses(self, expense_rows: list, refund_rows: list) -> list:
"""
处理支出记录
京东账单特点:
1. 已全额退款的记录金额显示为 "金额(已全额退款)"
2. 部分退款可能有单独的退款记录
"""
# 构建退款索引(按订单号)
order_refunds = {}
for row in refund_rows:
if len(row) >= 9:
order_no = row[8].strip() # 交易订单号
amount = parse_amount(row[3]) # 金额
if order_no:
if order_no not in order_refunds:
order_refunds[order_no] = Decimal("0")
order_refunds[order_no] += amount
print(f" 退款记录: {row[0]} | {row[1]} | {amount}")
final_rows = []
for row in expense_rows:
if len(row) < 9:
continue
order_no = row[8].strip() # 交易订单号
amount, is_fully_refunded = self._parse_jd_amount(row[3])
# 情况1金额已标注"已全额退款"
if is_fully_refunded:
self.stats["fully_refunded"] += 1
desc = row[2][:25] if len(row[2]) > 25 else row[2]
print(f" 全额退款删除: {row[0]} | {row[1]} | {desc}... | {row[3]}")
continue
# 情况2检查是否有对应的退款记录
refund_amount = order_refunds.get(order_no, Decimal("0"))
if refund_amount > 0:
if refund_amount >= amount:
# 全额退款
self.stats["fully_refunded"] += 1
desc = row[2][:25] if len(row[2]) > 25 else row[2]
print(f" 全额退款删除: {row[0]} | {row[1]} | {desc}... | 原{amount}")
else:
# 部分退款
remaining = amount - refund_amount
new_row = row.copy()
new_row[3] = format_amount(remaining)
remark = f"原金额{amount}元,退款{refund_amount}"
final_rows.append((new_row, remark))
self.stats["partially_refunded"] += 1
print(f" 部分退款: {row[0]} | {row[1]} | 原{amount}元 -> {format_amount(remaining)}")
else:
# 无退款,正常记录
if amount > 0:
final_rows.append((row, None))
else:
self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1
return final_rows
def _convert_and_reclassify(self, row_tuple: tuple) -> list:
"""
转换为对齐格式并重新分类
京东原始字段:
0: 交易时间, 1: 商户名称, 2: 交易说明, 3: 金额,
4: 收/付款方式, 5: 交易状态, 6: 收/支, 7: 交易分类,
8: 交易订单号, 9: 商家订单号, 10: 备注
对齐后字段:
交易时间, 交易分类, 交易对方, 对方账号, 商品说明,
收/支, 金额, 收/付款方式, 交易状态, 交易订单号, 商家订单号, 备注, 复核等级
"""
if isinstance(row_tuple, tuple):
row, remark = row_tuple
else:
row, remark = row_tuple, None
transaction_time = row[0]
merchant = row[1] # 商户名称
product = row[2] # 交易说明
amount, _ = self._parse_jd_amount(row[3])
payment_method = row[4] if len(row) > 4 else ""
status = row[5] if len(row) > 5 else ""
income_expense = row[6] if len(row) > 6 else "支出"
original_category = row[7] if len(row) > 7 else ""
order_no = row[8] if len(row) > 8 else ""
merchant_order_no = row[9] if len(row) > 9 else ""
final_remark = remark if remark else (row[10] if len(row) > 10 else "/")
# 使用京东专属分类推断
category, is_certain, review_level = infer_jd_category(merchant, product, original_category)
# 复核等级映射: 0=空, 1=LOW, 2=HIGH
review_marks = {0: "", 1: "LOW", 2: "HIGH"}
review_mark = review_marks.get(review_level, "")
return [
transaction_time,
category,
merchant,
"/", # 对方账号(京东无此字段)
product,
income_expense,
format_amount(amount),
payment_method,
status,
order_no,
merchant_order_no,
final_remark,
review_mark
]
def reclassify(self, rows: list) -> list:
"""
重新分类京东账单
京东账单在 _convert_and_reclassify 中已完成分类
此方法为接口兼容保留
"""
return rows
def _print_expense_summary(self, expense_rows: list):
"""打印支出统计"""
total = Decimal("0")
categories = {}
for row in expense_rows:
if row[5] == "支出":
amt = Decimal(row[6])
total += amt
cat = row[1]
categories[cat] = categories.get(cat, Decimal("0")) + amt
print(f"清理后支出总额: ¥{total}")
print("\n=== 按分类统计 ===")
for cat, amt in sorted(categories.items(), key=lambda x: -x[1]):
print(f" {cat}: ¥{amt}")
def main():
"""命令行入口"""
parser = create_arg_parser("清理京东白条账单数据")
args = parser.parse_args()
from .base import compute_date_range
cleaner = JDCleaner(args.input_file, args.output_file)
start_date, end_date = compute_date_range(args)
cleaner.set_date_range(start_date, end_date)
cleaner.clean()
if __name__ == "__main__":
main()

View File

@@ -54,7 +54,23 @@ class WechatCleaner(BaseCleaner):
# 第三步:处理退款(包括转账退款)
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" 全额退款删除: {self.stats['fully_refunded']}")
print(f" 部分退款调整: {self.stats['partially_refunded']}")

View File

@@ -66,8 +66,6 @@
- 高速
- 过路费
- "12306"
- 携程
- 飞猪
# 充值缴费
充值缴费:
@@ -84,6 +82,40 @@
- 供暖
- 暖气
# 宠物相关
宠物相关:
- 宠物
- 猫咪
-
- 猫粮
- 狗粮
- 猫砂
- 喂水
- 猫零食
- 犬猫
# 数码电器
数码电器:
- 饮水机
- 净水
- 制冰
- nas
- 存储
- 硬盘
- 电脑
- 手机
- 平板
- 电器
- 小家电
- 充电
- 数据线
- 路由器
- 音箱
- 耳机
- 键盘
- 鼠标
- 显示器
# 运动健身
运动健身:
- 健身
@@ -106,6 +138,31 @@
- 攀岩
- 骑行装备
# 酒店旅游(优先级高于文化休闲)
酒店旅游:
- 酒店
- 宾馆
- 民宿
- 客栈
- 旅馆
- 如家
- 汉庭
- 7天
- 希尔顿
- 万豪
- 喜来登
- 亚朵
- 华住
- 携程
- 飞猪
- 去哪儿
- 同程
- 旅游
- 旅行
- 景区
- 门票
- 度假
# 文化休闲(优先级高于日用百货,避免万达影城被识别为万达商场)
文化休闲:
- 影城 # 电影院
@@ -113,9 +170,9 @@
- 电影
- 游戏
- 娱乐
- 旅游
- 景区
- 门票
-
- 图书
- 文娱
- 会员
- 视频
- 音乐
@@ -127,11 +184,6 @@
- 酒吧
- 演出
- 演唱会
- 酒店 # 美团酒店
- 如家
- 汉庭
- 7天
- 民宿
# 日用百货 - 盒马放在前面,避免被餐饮匹配
日用百货:
@@ -157,6 +209,15 @@
- 妍丽 # AFIONA妍丽美妆店
- 屈臣氏
- 丝芙兰
- 保鲜盒
- 收纳
- 厨房
- 清洁
- 洗衣
- 纸巾
- 毛巾
- 床品
- 家居
# 餐饮美食
餐饮美食:
@@ -220,11 +281,6 @@
- 粒上皇 # 炒货零食店
- 盒马
# 转账红包
转账红包:
- 红包
- 转账给
收入分类:
退款:

View File

@@ -0,0 +1,48 @@
# =============================================================================
# 京东账单分类映射配置
# 将京东原始分类转换为统一分类
# =============================================================================
# =============================================================================
# 京东原始分类 -> 统一分类映射
# 京东账单中的"交易分类"字段可能包含以下值:
# - 余额、小金库、白条:财务操作(已在清洗时过滤)
# - 其他、其他网购、网购:需要根据商品说明进一步判断
# - 食品酒饮:餐饮美食
# - 数码电器、电脑办公:数码电器
# - 日用百货:日用百货
# - 图书文娱:文化休闲
# =============================================================================
分类映射:
# 直接映射(京东分类 -> 统一分类)
食品酒饮: 餐饮美食
数码电器: 数码电器
电脑办公: 数码电器
日用百货: 日用百货
图书文娱: 文化休闲
# 需要进一步判断的分类(返回空字符串,由关键词推断)
其他: ""
其他网购: ""
网购: ""
# 财务类(通常已被过滤,但以防万一)
余额: ""
小金库: ""
白条: ""
# =============================================================================
# 商户名称 -> 统一分类映射
# 根据商户名称直接映射分类,无需关键词匹配
# =============================================================================
商户映射:
京东外卖: 餐饮美食
# =============================================================================
# 默认分类
# 当无法匹配任何规则时使用
# =============================================================================
默认分类: 其他支出

View File

@@ -49,7 +49,7 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
从内容和文件名检测账单类型
Returns:
'alipay', 'wechat', 或 ''
'alipay', 'wechat', 'jd',''
"""
# 从文件名检测
filename_lower = filename.lower()
@@ -57,6 +57,8 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
return 'alipay'
if '微信' in filename or 'wechat' in filename_lower:
return 'wechat'
if '京东' in filename or 'jd' in filename_lower:
return 'jd'
# 从内容检测
# 支付宝特征: 有 "交易分类" 和 "对方账号" 列
@@ -67,6 +69,12 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
if '交易类型' in content and '金额(元)' in content:
return 'wechat'
# 京东特征: 有 "商户名称" 和 "交易说明" 列,或头部包含 "京东账号名"
if '商户名称' in content and '交易说明' in content:
return 'jd'
if '京东账号名' in content:
return 'jd'
return ''

View File

@@ -22,7 +22,7 @@ if sys.stdout.encoding != 'utf-8':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
from cleaners.base import compute_date_range_from_values
from cleaners import AlipayCleaner, WechatCleaner
from cleaners import AlipayCleaner, WechatCleaner, JDCleaner
from category import infer_category, get_all_categories, get_all_income_categories
from converter import convert_bill_file
@@ -43,7 +43,7 @@ class CleanRequest(BaseModel):
start: Optional[str] = None
end: Optional[str] = None
format: Optional[str] = "csv"
bill_type: Optional[str] = "auto" # auto, alipay, wechat
bill_type: Optional[str] = "auto" # auto, alipay, wechat, jd
class CleanResponse(BaseModel):
@@ -52,6 +52,7 @@ class CleanResponse(BaseModel):
bill_type: str
message: str
output_path: Optional[str] = None
unresolved_refunds: list[dict] = []
class CategoryRequest(BaseModel):
@@ -90,7 +91,7 @@ def detect_bill_type(filepath: str) -> str | None:
检测账单类型
Returns:
'alipay' | 'wechat' | None
'alipay' | 'wechat' | 'jd' | None
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
@@ -107,6 +108,14 @@ def detect_bill_type(filepath: str) -> str | None:
if "交易类型" in line and "金额(元)" in line:
return "wechat"
# 京东特征:表头包含 "商户名称" 和 "交易说明"
if "商户名称" in line and "交易说明" in line:
return "jd"
# 京东特征:头部信息包含 "京东账号名"
if "京东账号名" in line:
return "jd"
# 数据行特征
if line.startswith("202"):
if "" in line:
@@ -130,42 +139,44 @@ def do_clean(
start: str = None,
end: str = None,
output_format: str = "csv"
) -> tuple[bool, str, str]:
) -> tuple[bool, str, str, list[dict]]:
"""
执行清洗逻辑
Returns:
(success, bill_type, message)
(success, bill_type, message, unresolved_refunds)
"""
# 检查文件是否存在
if not Path(input_path).exists():
return False, "", f"文件不存在: {input_path}"
return False, "", f"文件不存在: {input_path}", []
# 检测账单类型
if bill_type == "auto":
detected_type = detect_bill_type(input_path)
if detected_type is None:
return False, "", "无法识别账单类型"
return False, "", "无法识别账单类型", []
bill_type = detected_type
# 计算日期范围
start_date, end_date = compute_date_range_from_values(year, month, start, end)
# 创建对应的清理器
try:
if bill_type == "alipay":
cleaner = AlipayCleaner(input_path, output_path, output_format)
elif bill_type == "jd":
cleaner = JDCleaner(input_path, output_path, output_format)
else:
cleaner = WechatCleaner(input_path, output_path, output_format)
cleaner.set_date_range(start_date, end_date)
cleaner.clean()
type_names = {"alipay": "支付宝", "wechat": "微信"}
return True, bill_type, f"{type_names[bill_type]}账单清洗完成"
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
return True, bill_type, f"{type_names.get(bill_type, bill_type)}账单清洗完成", cleaner.unresolved_refunds
except Exception as e:
return False, bill_type, f"清洗失败: {str(e)}"
return False, bill_type, f"清洗失败: {str(e)}", []
# =============================================================================
@@ -205,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,
output_path=request.output_path,
bill_type=request.bill_type or "auto",
@@ -215,15 +226,16 @@ async def clean_bill(request: CleanRequest):
end=request.end,
output_format=request.format or "csv"
)
if not success:
raise HTTPException(status_code=400, detail=message)
return CleanResponse(
success=True,
bill_type=bill_type,
message=message,
output_path=request.output_path
output_path=request.output_path,
unresolved_refunds=unresolved_refunds
)
@@ -254,7 +266,7 @@ async def clean_bill_upload(
output_path = tmp_output.name
try:
success, detected_type, message = do_clean(
success, detected_type, message, unresolved_refunds = do_clean(
input_path=input_path,
output_path=output_path,
bill_type=bill_type or "auto",
@@ -264,15 +276,16 @@ async def clean_bill_upload(
end=end,
output_format=format or "csv"
)
if not success:
raise HTTPException(status_code=400, detail=message)
return CleanResponse(
success=True,
bill_type=detected_type,
message=message,
output_path=output_path
output_path=output_path,
unresolved_refunds=unresolved_refunds
)
finally:
@@ -324,7 +337,7 @@ async def detect_bill_type_api(file: UploadFile = File(...)):
"""
检测账单类型
上传文件后自动检测是支付宝还是微信账单
上传文件后自动检测是支付宝、微信还是京东账单
"""
suffix = Path(file.filename).suffix or ".csv"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
@@ -336,10 +349,10 @@ async def detect_bill_type_api(file: UploadFile = File(...)):
if bill_type is None:
raise HTTPException(status_code=400, detail="无法识别账单类型")
type_names = {"alipay": "支付宝", "wechat": "微信"}
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
return {
"bill_type": bill_type,
"display_name": type_names[bill_type]
"display_name": type_names.get(bill_type, bill_type)
}
finally:
if os.path.exists(tmp_path):

117
analyzer/test_jd_cleaner.py Normal file
View File

@@ -0,0 +1,117 @@
"""
测试京东账单清洗器
"""
import zipfile
import tempfile
import os
import csv
import sys
# 确保输出使用 UTF-8
sys.stdout.reconfigure(encoding='utf-8')
def test_jd_cleaner():
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 tempfile.TemporaryDirectory() as tmpdir:
zf.extractall(tmpdir, pwd=b'683263')
# Find CSV file
for f in os.listdir(tmpdir):
if f.endswith('.csv'):
input_file = os.path.join(tmpdir, f)
output_file = os.path.join(tmpdir, 'output.csv')
print(f"Input file: {f}")
print("-" * 60)
# Run cleaner
from cleaners.jd import JDCleaner
cleaner = JDCleaner(input_file, output_file)
cleaner.clean()
# Read output and show review levels
print("\n" + "=" * 60)
print("OUTPUT REVIEW LEVELS")
print("=" * 60)
with open(output_file, 'r', encoding='utf-8') as of:
reader = csv.reader(of)
header = next(reader)
review_idx = header.index('复核等级') if '复核等级' in header else -1
cat_idx = header.index('交易分类') if '交易分类' in header else -1
merchant_idx = header.index('交易对方') if '交易对方' in header else -1
desc_idx = header.index('商品说明') if '商品说明' in header else -1
stats = {'': 0, 'LOW': 0, 'HIGH': 0}
rows_needing_review = []
for row in reader:
review = row[review_idx] if review_idx >= 0 else ''
stats[review] = stats.get(review, 0) + 1
if review: # Collect rows that need review
cat = row[cat_idx] if cat_idx >= 0 else ''
merchant = row[merchant_idx][:20] if merchant_idx >= 0 else ''
desc = row[desc_idx][:25] if desc_idx >= 0 else ''
rows_needing_review.append((review, cat, merchant, desc))
# Print rows needing review
print(f"{'Level':<5} | {'Category':<12} | {'Merchant':<20} | Description")
print("-" * 70)
for review, cat, merchant, desc in rows_needing_review:
print(f"{review:<5} | {cat:<12} | {merchant:<20} | {desc}")
print("\n" + "=" * 60)
print("STATISTICS")
print("=" * 60)
print(f"No review (confident): {stats['']}")
print(f"LOW (keyword match): {stats['LOW']}")
print(f"HIGH (needs manual): {stats['HIGH']}")
print(f"Total: {sum(stats.values())}")
def test_infer_jd_category():
"""测试分类推断逻辑"""
from cleaners.jd import infer_jd_category
print("\n" + "=" * 60)
print("INFER_JD_CATEGORY TESTS")
print("=" * 60)
tests = [
# (商户, 商品, 原分类, 预期等级, 说明)
('京东外卖', '火鸡面', '', 0, '商户映射'),
('京东平台商户', 'xxx', '食品酒饮', 0, '原分类映射'),
('京东平台商户', 'xxx', '数码电器', 0, '原分类映射'),
('京东平台商户', 'xxx', '日用百货', 0, '原分类映射'),
('京东平台商户', 'xxx', '图书文娱', 0, '原分类映射'),
('京东平台商户', '猫粮', '其他', 1, '空映射+关键词成功'),
('京东平台商户', '咖啡', '其他网购', 1, '空映射+关键词成功'),
('京东平台商户', 'xxx', '其他', 2, '空映射+关键词失败'),
('京东平台商户', 'xxx', '家居用品', 2, '未知分类'),
('京东平台商户', 'xxx', '母婴', 2, '未知分类'),
('京东平台商户', 'xxx', '', 2, '无原分类+关键词失败'),
]
level_map = {0: 'NONE', 1: 'LOW', 2: 'HIGH'}
print(f"{'Merchant':<15} | {'Product':<8} | {'OrigCat':<10} | {'Result':<12} | {'Level':<5} | {'Expected':<5} | Note")
print("-" * 90)
all_pass = True
for merchant, product, orig_cat, expected_level, note in tests:
cat, certain, level = infer_jd_category(merchant, product, orig_cat)
status = "" if level == expected_level else ""
if level != expected_level:
all_pass = False
print(f"{merchant:<15} | {product:<8} | {orig_cat or '(empty)':<10} | {cat:<12} | {level_map[level]:<5} | {level_map[expected_level]:<5} | {note} {status}")
print("\n" + ("All tests passed!" if all_pass else "Some tests FAILED!"))
if __name__ == '__main__':
test_infer_jd_category()
print("\n")
test_jd_cleaner()

62
deploy.sh Normal file
View 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

View 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

View File

@@ -27,8 +27,8 @@ services:
# Go 后端服务
server:
build:
context: ./server
dockerfile: Dockerfile
context: .
dockerfile: server/Dockerfile
container_name: billai-server
restart: unless-stopped
ports:

3
runner/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Gitea Runner 配置
# 从 Gitea 仓库获取 TokenSettings -> Actions -> Runners -> Create new Runner
GITEA_RUNNER_REGISTRATION_TOKEN=你的Token

49
runner/config.yaml Normal file
View 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
View 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

View File

@@ -1,8 +1,9 @@
# Go 服务 Dockerfile
# 多阶段构建:编译阶段 + 运行阶段
# 构建上下文项目根目录docker-compose context: .
# ===== 编译阶段 =====
FROM golang:1.21-alpine AS builder
FROM golang:1.24-alpine AS builder
WORKDIR /build
@@ -10,11 +11,11 @@ WORKDIR /build
ENV GOPROXY=https://goproxy.cn,direct
# 先复制依赖文件,利用 Docker 缓存
COPY go.mod go.sum ./
COPY server/go.mod server/go.sum ./
RUN go mod download
# 复制源代码并编译
COPY . .
COPY 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/config.yaml .
# 复制项目根目录的 CHANGELOG.md
COPY CHANGELOG.md .
# 创建必要目录
RUN mkdir -p uploads outputs

View File

@@ -13,14 +13,26 @@ type CleanOptions struct {
// CleanResult 清洗结果
type CleanResult struct {
BillType string // 检测到的账单类型: alipay/wechat
Output string // 脚本输出信息
BillType string // 检测到的账单类型: alipay/wechat/jd
Output string // 脚本输出信息
UnresolvedRefunds []UnresolvedRefund // 本次清洗未在同批次内匹配到对应支出的退款
}
// UnresolvedRefund 本次清洗未在同批次内匹配到对应支出的退款
type UnresolvedRefund struct {
OrderNo string // 原订单号(去除退款后缀)
MerchantOrderNo string // 商家订单号(备用匹配字段)
RefundOrderNo string // 退款行自身的完整订单号(用于备注追溯)
Amount float64 // 退款金额
Time string // 退款时间
Merchant string // 交易对方
Description string // 商品说明
}
// ConvertResult 格式转换结果
type ConvertResult struct {
OutputPath string // 转换后的文件路径
BillType string // 检测到的账单类型: alipay/wechat
BillType string // 检测到的账单类型: alipay/wechat/jd
}
// Cleaner 账单清洗器接口

View File

@@ -29,10 +29,22 @@ type CleanRequest struct {
// CleanResponse HTTP 清洗响应
type CleanResponse struct {
Success bool `json:"success"`
BillType string `json:"bill_type"`
Message string `json:"message"`
OutputPath string `json:"output_path,omitempty"`
Success bool `json:"success"`
BillType string `json:"bill_type"`
Message string `json:"message"`
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 错误响应
@@ -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{
BillType: cleanResp.BillType,
Output: cleanResp.Message,
BillType: cleanResp.BillType,
Output: cleanResp.Message,
UnresolvedRefunds: unresolvedRefunds,
}, nil
}

View File

@@ -87,6 +87,9 @@ func detectBillTypeFromOutput(output string) string {
if strings.Contains(output, "微信") {
return "wechat"
}
if strings.Contains(output, "京东") {
return "jd"
}
return ""
}

View File

@@ -1,7 +1,7 @@
# BillAI 服务器配置文件
# 应用版本
version: "1.0.7"
version: "1.0.8"
# 服务配置
server:

View File

@@ -1,13 +1,14 @@
module billai-server
go 1.21
go 1.24.0
require (
github.com/gin-gonic/gin v1.9.1
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
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
)
@@ -30,16 +31,21 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // 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/ugorji/go/codec v1.2.11 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // 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
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
)

View File

@@ -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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.1/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.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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
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/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
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/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI=
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-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.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
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/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-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.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
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-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.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-20201119102817-f84b799fce68/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-20220722155257-8c9f86f7a55f/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -98,7 +98,11 @@ func Login(c *gin.Context) {
secret := config.Global.JWTSecret
if secret == "" {
secret = "billai-default-secret"
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "服务器 JWT 配置缺失",
})
return
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -144,7 +148,12 @@ func ValidateToken(c *gin.Context) {
secret := config.Global.JWTSecret
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) {

View File

@@ -2,7 +2,6 @@ package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -18,7 +17,7 @@ type ListBillsRequest struct {
StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD
EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD
Category string `form:"category"` // 分类筛选
Type string `form:"type"` // 账单类型 alipay/wechat
Type string `form:"type"` // 账单类型 alipay/wechat/jd
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
}
@@ -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 月度统计响应
type MonthlyStatsResponse struct {
Result bool `json:"result"`
@@ -202,6 +189,13 @@ func MonthlyStats(c *gin.Context) {
// ReviewStats 获取待复核数据统计
func ReviewStats(c *gin.Context) {
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusServiceUnavailable, model.ReviewResponse{
Result: false,
Message: "数据库未连接",
})
return
}
// 从MongoDB查询所有需要复核的账单
bills, err := repo.GetBillsNeedReview()

View 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,
})
}

View File

@@ -14,7 +14,7 @@ type DeleteBillResponse struct {
Message string `json:"message,omitempty"`
}
// DeleteBill DELETE /api/bills/:id 删除清洗后的账单记录
// DeleteBill POST /api/bills/:id/delete 删除清洗后的账单记录
func DeleteBill(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {

134
server/handler/export.go Normal file
View 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
}

View File

@@ -1,61 +1,59 @@
package handler
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"billai-server/config"
"billai-server/model"
"billai-server/service"
"billai-server/repository"
)
// Review 获取需要复核的记录
func Review(c *gin.Context) {
// 获取文件名参
fileName := c.Query("file")
if fileName == "" {
c.JSON(http.StatusBadRequest, model.ReviewResponse{
// 获取数
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
Result: false,
Message: "请提供文件名参数 (file)",
Message: "数据库未连接",
})
return
}
// 构建文件路径
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
filePath := filepath.Join(outputDirAbs, fileName)
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, model.ReviewResponse{
// 从MongoDB查询所有需要复核的账单
bills, err := repo.GetBillsNeedReview()
if err != nil {
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
Result: false,
Message: "文件不存在: " + fileName,
Message: "查询失败: " + err.Error(),
})
return
}
// 判断文件格式
format := "csv"
if strings.HasSuffix(fileName, ".json") {
format = "json"
}
// 提取需要复核的记录
records := service.ExtractNeedsReview(filePath, format)
// 统计高低优先级数量
// 统计高低优先级数量并转换为 ReviewRecord
highCount := 0
lowCount := 0
for _, r := range records {
if r.ReviewLevel == "HIGH" {
records := make([]model.ReviewRecord, 0, len(bills))
for _, bill := range bills {
if bill.ReviewLevel == "HIGH" {
highCount++
} else if r.ReviewLevel == "LOW" {
} else if bill.ReviewLevel == "LOW" {
lowCount++
}
records = append(records, model.ReviewRecord{
Time: bill.Time.Time().Format("2006-01-02 15:04:05"),
Category: bill.Category,
Merchant: bill.Merchant,
Description: bill.Description,
IncomeExpense: bill.IncomeExpense,
Amount: fmt.Sprintf("%.2f", bill.Amount),
Remark: bill.Remark,
ReviewLevel: bill.ReviewLevel,
})
}
c.JSON(http.StatusOK, model.ReviewResponse{

View File

@@ -15,6 +15,7 @@ import (
"billai-server/config"
"billai-server/model"
"billai-server/repository"
"billai-server/service"
)
@@ -145,6 +146,8 @@ func Upload(c *gin.Context) {
billType = "alipay"
} else if strings.Contains(fileName, "微信") || strings.Contains(fileName, "wechat") {
billType = "wechat"
} else if strings.Contains(fileName, "京东") || strings.Contains(fileName, "jd") {
billType = "jd"
}
}
if billType == "" {
@@ -152,15 +155,15 @@ func Upload(c *gin.Context) {
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "无法识别账单类型,请指定 type 参数 (alipaywechat)",
Message: "无法识别账单类型,请指定 type 参数 (alipay/wechat/jd)",
})
return
}
if billType != "alipay" && billType != "wechat" {
if billType != "alipay" && billType != "wechat" && billType != "jd" {
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "账单类型无效,仅支持 alipaywechat",
Message: "账单类型无效,仅支持 alipay/wechat/jd",
})
return
}
@@ -220,7 +223,7 @@ func Upload(c *gin.Context) {
End: req.End,
Format: req.Format,
}
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
if cleanErr != nil {
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusInternalServerError, model.UploadResponse{
@@ -252,22 +255,79 @@ func Upload(c *gin.Context) {
}
service.CleanupExtractedFiles(extractedFiles)
// 13. 返回成功响应
repo := repository.GetRepository()
// 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录
var jdRelatedDeleted int64
if billType == "jd" && repo != nil {
deleted, err := repo.SoftDeleteJDRelatedBills()
if err != nil {
fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err)
} else if deleted > 0 {
jdRelatedDeleted = deleted
fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted)
}
}
// 14. 核销跨批次退款(支付宝:本次清洗中未在同批次内匹配到对应支出的退款)
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)
}
}
}
// 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)
if dedupResult.DuplicateCount > 0 {
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
}
if jdRelatedDeleted > 0 {
message = fmt.Sprintf("%s标记删除 %d 条重复的京东订单", message, jdRelatedDeleted)
}
if reconciledCount > 0 {
message = fmt.Sprintf("%s核销退款 %d 条", message, reconciledCount)
}
c.JSON(http.StatusOK, model.UploadResponse{
Result: true,
Message: message,
Data: &model.UploadData{
BillType: billType,
FileURL: fmt.Sprintf("/download/%s", outputFileName),
FileName: outputFileName,
RawCount: rawCount,
CleanedCount: cleanedCount,
DuplicateCount: dedupResult.DuplicateCount,
BillType: billType,
FileURL: fmt.Sprintf("/download/%s", outputFileName),
FileName: outputFileName,
RawCount: rawCount,
CleanedCount: cleanedCount,
DuplicateCount: dedupResult.DuplicateCount,
JDRelatedDeleted: jdRelatedDeleted,
ReconciledRefundCount: reconciledCount,
},
})
}

View File

@@ -38,7 +38,13 @@ func AuthRequired() gin.HandlerFunc {
secret := config.Global.JWTSecret
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) {

View File

@@ -70,7 +70,7 @@ func (t LocalTime) Time() time.Time {
// RawBill 原始账单记录(存储上传的原始数据)
type RawBill struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat/jd
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
RowIndex int `bson:"row_index" json:"row_index"` // 原始行号
@@ -81,16 +81,17 @@ type RawBill struct {
// CleanedBill 清洗后账单记录(标准化后的数据)
type CleanedBill struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat/jd
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
Time LocalTime `bson:"time" json:"time"` // 交易时间(本地时间格式)
Category string `bson:"category" json:"category"` // 交易分类
Merchant string `bson:"merchant" json:"merchant"` // 交易对方
Description string `bson:"description" json:"description"` // 商品说明
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
Amount float64 `bson:"amount" json:"amount"` // 金额
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
Amount float64 `bson:"amount" json:"amount"` // 金额
OriginalAmount float64 `bson:"original_amount,omitempty" json:"original_amount,omitempty"` // 原始金额(入库时),用于微信跨批次退款核销
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
Status string `bson:"status" json:"status"` // 交易状态
Remark string `bson:"remark" json:"remark"` // 备注
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空

View File

@@ -2,7 +2,7 @@ package model
// UploadRequest 上传请求参数
type UploadRequest struct {
Type string `form:"type"` // 账单类型: alipay/wechat可选会自动检测
Type string `form:"type"` // 账单类型: alipay/wechat/jd(可选,会自动检测)
Password string `form:"password"` // ZIP 文件密码(可选)
Year string `form:"year"` // 年份筛选
Month string `form:"month"` // 月份筛选

View File

@@ -2,12 +2,14 @@ package model
// UploadData 上传响应数据
type UploadData struct {
BillType string `json:"bill_type,omitempty"` // alipay/wechat
FileURL string `json:"file_url,omitempty"` // 下载链接
FileName string `json:"file_name,omitempty"` // 文件名
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
BillType string `json:"bill_type,omitempty"` // alipay/wechat/jd
FileURL string `json:"file_url,omitempty"` // 下载链接
FileName string `json:"file_name,omitempty"` // 文件名
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
JDRelatedDeleted int64 `json:"jd_related_deleted,omitempty"` // 软删除的京东关联记录数(其他来源中描述包含京东订单号的记录)
ReconciledRefundCount int `json:"reconciled_refund_count,omitempty"` // 跨批次核销的退款记录数
}
// UploadResponse 上传响应

View File

@@ -4,6 +4,7 @@ package mongo
import (
"context"
"fmt"
"math"
"time"
"go.mongodb.org/mongo-driver/bson"
@@ -16,6 +17,9 @@ import (
"billai-server/repository"
)
// refundEpsilon 退款核销后剩余金额的容差阈值,小于该值视为已全额退款
const refundEpsilon = 0.005
// Repository MongoDB 账单存储实现
type Repository struct {
client *mongo.Client
@@ -445,7 +449,12 @@ func (r *Repository) DeleteCleanedBillByID(id string) error {
defer cancel()
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)
if err != nil {
return fmt.Errorf("soft delete bill failed: %w", err)
@@ -458,6 +467,184 @@ func (r *Repository) DeleteCleanedBillByID(id string) error {
return nil
}
// SoftDeleteJDRelatedBills 软删除描述中包含"京东-订单编号"的非京东账单
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
func (r *Repository) SoftDeleteJDRelatedBills() (int64, error) {
if r.cleanedCollection == nil {
return 0, fmt.Errorf("cleaned collection not initialized")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 筛选条件:
// 1. 账单类型不是 jd只处理微信、支付宝等其他来源
// 2. 描述中包含"京东-订单编号"
// 3. 尚未被删除
filter := bson.M{
"bill_type": bson.M{"$ne": "jd"},
"description": bson.M{"$regex": "京东-订单编号", "$options": ""},
"is_deleted": bson.M{"$ne": true},
}
update := bson.M{
"$set": bson.M{
"is_deleted": true,
"updated_at": time.Now(),
},
}
result, err := r.cleanedCollection.UpdateMany(ctx, filter, update)
if err != nil {
return 0, fmt.Errorf("soft delete JD related bills failed: %w", err)
}
return result.ModifiedCount, nil
}
// 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 客户端(用于兼容旧代码)
func (r *Repository) GetClient() *mongo.Client {
return r.client

View File

@@ -51,4 +51,21 @@ type BillRepository interface {
// CountRawByField 按字段统计原始数据数量
CountRawByField(fieldName, value string) (int64, error)
// SoftDeleteJDRelatedBills 软删除描述中包含"京东-订单编号"的非京东账单
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
// 返回: 删除数量、错误
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)
}

View File

@@ -46,6 +46,9 @@ func setupAPIRoutes(r *gin.Engine) {
api.POST("/auth/login", handler.Login)
api.GET("/auth/validate", handler.ValidateToken)
// 公开接口(无需登录)
api.GET("/changelog", handler.GetChangelog)
// 需要登录的 API
authed := api.Group("/")
authed.Use(middleware.AuthRequired())
@@ -59,11 +62,14 @@ func setupAPIRoutes(r *gin.Engine) {
// 账单查询
authed.GET("/bills", handler.ListBills)
// 导出账单
authed.GET("/bills/export", handler.ExportBills)
// 编辑账单
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)

View File

@@ -105,6 +105,8 @@ func ExtractZip(zipPath, destDir, password string) (*ExtractResult, error) {
result.BillType = "alipay"
} else if strings.Contains(fileName, "微信") || strings.Contains(strings.ToLower(fileName), "wechat") {
result.BillType = "wechat"
} else if strings.Contains(fileName, "京东") || strings.Contains(strings.ToLower(fileName), "jd") {
result.BillType = "jd"
}
}
}

View File

@@ -23,13 +23,21 @@ func getRepo() repository.BillRepository {
return repository.GetRepository()
}
// WechatRefundUpdate 微信重复行中携带的退款信息(用于跨批次退款核销)
type WechatRefundUpdate struct {
TransactionID string // 原消费行的交易单号
FullRefund bool // 是否全额退款(已全额退款)
CumulativeRefundAmount float64 // 累计退款金额(已退款(¥X)中的 X与原始金额相减得剩余
}
// DeduplicateResult 去重结果
type DeduplicateResult struct {
OriginalCount int // 原始记录数
DuplicateCount int // 重复记录数
NewCount int // 新记录数
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
BillType string // 检测到的账单类型
OriginalCount int // 原始记录数
DuplicateCount int // 重复记录数
NewCount int // 新记录数
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
BillType string // 检测到的账单类型
WechatRefundUpdates []WechatRefundUpdate // 微信重复行中检测到的退款状态(用于跨批次核销)
}
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
@@ -75,6 +83,17 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
return result, nil
}
// 对于微信账单,找到"当前状态"列的索引,用于检测退款状态
wechatStatusIdx := -1
if billType == "wechat" {
for i, col := range header {
if col == "当前状态" {
wechatStatusIdx = i
break
}
}
}
// 检查每行是否重复
var newRows [][]string
for _, row := range dataRows {
@@ -101,6 +120,24 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
newRows = append(newRows, row)
} else {
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,
})
}
}
}
}
}
@@ -150,6 +187,10 @@ func detectBillTypeAndIdField(header []string) (billType string, idFieldIdx int)
if col == "交易类型" || col == "金额(元)" {
billType = "wechat"
}
// 京东特征
if col == "商户名称" || col == "交易说明" {
billType = "jd"
}
// 查找去重字段(优先使用交易订单号/交易号)
if col == "交易订单号" || col == "交易号" || col == "交易单号" {
@@ -333,6 +374,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
bill.ReviewLevel = row[idx]
}
bill.OriginalAmount = bill.Amount
bills = append(bills, bill)
}
@@ -427,6 +469,7 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string
bill.ReviewLevel = v
}
bill.OriginalAmount = bill.Amount
bills = append(bills, bill)
}
@@ -508,3 +551,28 @@ func parseAmount(s string) float64 {
}
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
View 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)
}

View 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))
}
}

View File

@@ -36,6 +36,9 @@ func DetectBillTypeFromOutput(output string) string {
if containsSubstring(output, "微信") {
return "wechat"
}
if containsSubstring(output, "京东") {
return "jd"
}
return ""
}

5
web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.git
.DS_Store
.svelte-kit
build

View File

@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "1.2.1",
"version": "1.3.1",
"type": "module",
"scripts": {
"dev": "vite dev",

3
web/src/app.d.ts vendored
View File

@@ -8,6 +8,9 @@ declare global {
// interface PageState {}
// interface Platform {}
}
// Vite 注入的全局变量
const __APP_VERSION__: string;
}
export {};

View File

@@ -5,7 +5,7 @@ import type { UIBill } from '$lib/models/bill';
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
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);
if (browser) {
@@ -42,7 +42,7 @@ export async function checkHealth(): Promise<boolean> {
}
// 类型定义
export type BillType = 'alipay' | 'wechat';
export type BillType = 'alipay' | 'wechat' | 'jd';
export interface UploadData {
bill_type: BillType;
@@ -128,17 +128,6 @@ export async function uploadBill(
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> {
const response = await apiFetch(`${API_BASE}/api/monthly-stats`);
@@ -327,6 +316,46 @@ export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsRe
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 {
time: string;
@@ -403,8 +432,8 @@ export interface DeleteBillResponse {
// 删除账单(软删除)
export async function deleteBill(id: string): Promise<DeleteBillResponse> {
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
method: 'DELETE'
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}/delete`, {
method: 'POST'
});
if (!response.ok) {

View 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>

View File

@@ -25,7 +25,7 @@
interface Props {
open?: boolean;
record?: UIBill | null;
categories?: string[];
categories?: readonly string[];
title?: string;
viewDescription?: string;
@@ -263,7 +263,7 @@
{:else}
<div>
<div class="text-center mb-6">
<div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
<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)}
</div>
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>

View File

@@ -16,6 +16,7 @@
pageSize?: number;
categories?: string[];
onUpdate?: (updated: UIBill, original: UIBill) => void;
onDelete?: (deleted: UIBill) => void;
}
let {
@@ -24,7 +25,8 @@
showDescription = true,
pageSize = 10,
categories = [],
onUpdate
onUpdate,
onDelete
}: Props = $props();
// 排序状态
@@ -112,6 +114,24 @@
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(() => {
records;
@@ -280,4 +300,6 @@
viewDescription="查看这笔支出的详细信息"
editDescription="修改这笔支出的信息"
onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
allowDelete={true}
/>

View File

@@ -18,9 +18,10 @@
records: UIBill[];
categories?: string[];
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) {
// 更新 records 数组
@@ -46,6 +47,28 @@
// 传播到父组件
onUpdate?.(updated, original);
}
function handleRecordDeleted(deleted: UIBill) {
const idx = records.findIndex(r =>
r === deleted ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (idx !== -1) {
records.splice(idx, 1);
records = [...records];
}
const dateIdx = selectedDateRecords.findIndex(r =>
r === deleted ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (dateIdx !== -1) {
selectedDateRecords.splice(dateIdx, 1);
selectedDateRecords = [...selectedDateRecords];
}
onDelete?.(deleted);
}
// Dialog 状态
let dialogOpen = $state(false);
@@ -176,6 +199,20 @@
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;
// 根据天数决定聚合粒度
@@ -923,6 +960,7 @@
pageSize={8}
{categories}
onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
/>
</div>
{:else}

View File

@@ -8,9 +8,10 @@
records: UIBill[];
categories: string[]; // 可用的分类列表
onUpdate?: (record: UIBill) => void;
onDelete?: (record: UIBill) => void;
}
let { records, categories, onUpdate }: Props = $props();
let { records, categories, onUpdate, onDelete }: Props = $props();
let dialogOpen = $state(false);
let selectedRecord = $state<UIBill | null>(null);
@@ -32,6 +33,26 @@
selectedRecord = 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>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
@@ -80,6 +101,8 @@
viewDescription="查看这笔支出的完整信息"
editDescription="修改这笔支出的信息"
onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
allowDelete={true}
>
{#snippet titleExtra({ isEditing })}
{#if selectedRank <= 3 && !isEditing}

View File

@@ -14,6 +14,7 @@ export const categories = [
'服饰鞋包',
'宠物相关',
'住房物业',
'酒店旅游',
'退款',
'工资收入',
'投资理财',
@@ -40,6 +41,7 @@ export const expenseCategories = [
'服饰鞋包',
'宠物相关',
'住房物业',
'酒店旅游',
'其他支出',
] as const;

View File

@@ -10,6 +10,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Avatar from '$lib/components/ui/avatar';
import { Separator } from '$lib/components/ui/separator';
import ChangelogDrawer from '$lib/components/ChangelogDrawer.svelte';
// Icons
import Upload from '@lucide/svelte/icons/upload';
@@ -24,6 +25,10 @@
import User from '@lucide/svelte/icons/user';
import Bell from '@lucide/svelte/icons/bell';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Info from '@lucide/svelte/icons/info';
// 版本号(从 Vite 编译时注入)
const appVersion = __APP_VERSION__;
// Theme
import {
@@ -42,6 +47,7 @@
let checkingHealth = $state(true);
let isAuthenticated = $state(false);
let currentUser = $state<AuthUser | null>(null);
let changelogOpen = $state(false);
// 订阅认证状态
$effect(() => {
@@ -223,6 +229,18 @@
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
<!-- 版本号 -->
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<button {...props} onclick={() => changelogOpen = true} title="查看更新日志">
<Info class="size-4" />
<span>v{appVersion}</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
@@ -231,6 +249,7 @@
<!-- Footer: 用户信息 -->
<Sidebar.Footer>
<Sidebar.Menu>
<!-- 用户信息 -->
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@@ -344,4 +363,7 @@
</main>
</Sidebar.Inset>
</Sidebar.Provider>
<!-- Changelog 抽屉 -->
<ChangelogDrawer bind:open={changelogOpen} />
{/if}

View File

@@ -213,6 +213,8 @@
selectedType = 'alipay';
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
selectedType = 'wechat';
} else if (fileName.includes('京东') || fileName.includes('jd')) {
selectedType = 'jd';
}
}
@@ -263,7 +265,7 @@
<!-- 页面标题 -->
<div>
<h1 class="text-2xl font-bold tracking-tight">账单管理</h1>
<p class="text-muted-foreground">上传并分析您的支付宝、微信账单</p>
<p class="text-muted-foreground">上传并分析您的支付宝、微信、京东账单</p>
</div>
<!-- 统计卡片 -->
@@ -297,7 +299,7 @@
<Card.Header class="flex flex-row items-center justify-between space-y-0">
<div>
<Card.Title>上传账单</Card.Title>
<Card.Description>支持支付宝、微信账单 CSV、XLSX 或 ZIP 文件</Card.Description>
<Card.Description>支持支付宝、微信、京东账单 CSV、XLSX 或 ZIP 文件</Card.Description>
</div>
<Button variant="outline" size="sm" onclick={() => goto('/bills?tab=manual')}>
<Plus class="mr-2 h-4 w-4" />
@@ -397,6 +399,13 @@
>
微信
</Button>
<Button
variant={selectedType === 'jd' ? 'default' : 'outline'}
size="sm"
onclick={() => selectedType = 'jd'}
>
京东
</Button>
</div>
</div>
@@ -438,7 +447,7 @@
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">账单类型</span>
<Badge variant="secondary">
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : '微信'}
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : uploadResult.data?.bill_type === 'wechat' ? '微信' : '京东'}
</Badge>
</div>
<div class="flex items-center justify-between">

View File

@@ -126,6 +126,32 @@
}
}
}
function handleBillDeleted(deleted: UIBill) {
const idx = records.findIndex(r =>
r.id === (deleted as unknown as { id?: string }).id ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (idx !== -1) {
records.splice(idx, 1);
records = [...records];
}
const allIdx = allRecords.findIndex(r =>
r.id === (deleted as unknown as { id?: string }).id ||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
);
if (allIdx !== -1) {
allRecords.splice(allIdx, 1);
allRecords = [...allRecords];
}
if (deleted.incomeExpense === '支出') {
backendTotalExpense = Math.max(0, backendTotalExpense - deleted.amount);
} else if (deleted.incomeExpense === '收入') {
backendTotalIncome = Math.max(0, backendTotalIncome - deleted.amount);
}
}
// 分类列表按数据中出现次数排序
let sortedCategories = $derived(() => {
@@ -209,6 +235,8 @@
});
if (response.result && response.data) {
records = response.data.bills || [];
backendTotalExpense = response.data.total_expense || 0;
backendTotalIncome = response.data.total_income || 0;
if (records.length === 0) {
errorMessage = '暂无账单数据';
}
@@ -289,7 +317,12 @@
<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">
<!-- 分类支出排行 -->
@@ -307,7 +340,12 @@
</div>
<!-- Top 10 支出 -->
<TopExpenses records={topExpenses} categories={sortedCategories()} onUpdate={handleBillUpdated} />
<TopExpenses
records={topExpenses}
categories={sortedCategories()}
onUpdate={handleBillUpdated}
onDelete={handleBillDeleted}
/>
{:else}
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
<Card.Root>

View File

@@ -14,8 +14,10 @@
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
import { formatLocalDate, formatDateTime } from '$lib/utils';
import Loader2 from '@lucide/svelte/icons/loader-2';
import { categories } from '$lib/data/categories';
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 Search from '@lucide/svelte/icons/search';
import Receipt from '@lucide/svelte/icons/receipt';
@@ -25,9 +27,10 @@
import Filter from '@lucide/svelte/icons/filter';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Plus from '@lucide/svelte/icons/plus';
import List from '@lucide/svelte/icons/list';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Plus from '@lucide/svelte/icons/plus';
import List from '@lucide/svelte/icons/list';
import Download from '@lucide/svelte/icons/download';
// 状态
let isLoading = $state(false);
@@ -89,12 +92,7 @@
applyFilters();
}
// 分类列表(硬编码常用分类)
const categories = [
'餐饮美食', '交通出行', '生活服务', '日用百货',
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
];
async function loadBills() {
isLoading = true;
@@ -225,6 +223,29 @@
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>
<svelte:head>
@@ -244,6 +265,10 @@
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
刷新
</Button>
<Button variant="outline" onclick={handleExport} disabled={isExporting || totalRecords === 0}>
<Download class="mr-2 h-4 w-4 {isExporting ? 'animate-spin' : ''}" />
导出 Excel
</Button>
{/if}
</div>
</div>
@@ -380,13 +405,14 @@
<Label class="text-xs">来源</Label>
<Select.Root type="single" value={filterBillType || undefined} onValueChange={handleBillTypeChange}>
<Select.Trigger class="h-9 w-full">
<span class="text-sm">{filterBillType === 'alipay' ? '支付宝' : filterBillType === 'wechat' ? '微信' : filterBillType === 'manual' ? '手动' : '全部'}</span>
<span class="text-sm">{filterBillType === 'alipay' ? '支付宝' : filterBillType === 'wechat' ? '微信' : filterBillType === 'jd' ? '京东' : filterBillType === 'manual' ? '手动' : '全部'}</span>
</Select.Trigger>
<Select.Portal>
<Select.Content>
<Select.Item value="">全部</Select.Item>
<Select.Item value="alipay">支付宝</Select.Item>
<Select.Item value="wechat">微信</Select.Item>
<Select.Item value="jd">京东</Select.Item>
<Select.Item value="manual">手动</Select.Item>
</Select.Content>
</Select.Portal>
@@ -438,8 +464,8 @@
{formatDateTime(record.time)}
</Table.Cell>
<Table.Cell class="hidden xl:table-cell">
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : 'secondary')}>
{record.bill_type === 'manual' ? '手动输入' : (record.bill_type === 'alipay' ? '支付宝' : '微信')}
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : (record.bill_type === 'jd' ? 'destructive' : 'secondary'))}>
{record.bill_type === 'manual' ? '手动输入' : (record.bill_type === 'alipay' ? '支付宝' : (record.bill_type === 'jd' ? '京东' : '微信'))}
</Badge>
</Table.Cell>
<Table.Cell>

View File

@@ -8,6 +8,7 @@
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
import { updateBill } from '$lib/api';
import { categories } from '$lib/data/categories';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
@@ -80,12 +81,7 @@
let drawerOpen = $state(false);
let selectedBill = $state<UIBill | null>(null);
// 分类列表(用于编辑选择)
const categories = [
'餐饮美食', '交通出行', '生活服务', '日用百货',
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
];
// 点击行打开详情
function handleRowClick(record: CleanedBill) {

View File

@@ -2,10 +2,18 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'));
export default defineConfig({
plugins: [sveltekit(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version)
},
server: {
proxy: {
'/api': {