Compare commits

..

8 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
32 changed files with 857 additions and 239 deletions

1
.gitignore vendored
View File

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

158
AGENTS.md
View File

@@ -2,83 +2,81 @@
Guidelines for AI coding agents working on BillAI - a microservices bill analysis system. Guidelines for AI coding agents working on BillAI - a microservices bill analysis system.
**Version:** See `CHANGELOG.md` for current version. Latest tag usually matches.
## Architecture ## Architecture
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend, port 3000) - `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend, port 3000)
- `server/` - Go 1.21 + Gin + MongoDB (API, port 8080) - `server/` - Go 1.24 + Gin + MongoDB (API, port 8080)
- `analyzer/` - Python 3.12 + FastAPI (Data cleaning, port 8001) - `analyzer/` - Python 3.12 + FastAPI (Data cleaning, port 8001)
SvelteKit proxies `/api/*` requests to Go backend via `web/src/routes/api/[...path]/+server.ts`. **Proxy Caveat:** SvelteKit proxies `/api/*` to Go via `web/src/routes/api/[...path]/+server.ts`, but **only GET and POST are forwarded**. The Go backend intentionally uses `POST` for mutations (update, delete, manual create) to work around this. If you add PUT/PATCH/DELETE endpoints, you must also add them to the proxy.
## Build/Lint/Test Commands ## Build/Lint/Test Commands
### Frontend (web/) ### Frontend (`web/`)
```bash ```bash
npm run dev # Start dev server npm run dev # Start dev server
npm run build # Production build npm run build # Production build
npm run check # TypeScript check npm run check # TypeScript + Svelte check
npm run lint # Prettier + ESLint npm run lint # Prettier --check + ESLint
npm run format # Format with Prettier npm run format # Prettier --write
npm run test # Run all tests (CI mode) npm run test # vitest --run (CI mode)
npx vitest run src/xxx.spec.ts # Run single test file npx vitest run src/xxx.spec.ts # Run single test file
npx vitest run -t "pattern" # Run by name pattern
``` ```
### Backend (server/) **Note:** `web/` has a `yarn.lock` but scripts in `package.json` use `npm run`.
### Backend (`server/`)
```bash ```bash
go run . # Start server go run . # Start server (reads server/config.yaml)
go build -o server . # Build binary go build -o server .
go test ./... # Run all tests go test ./... # Run all tests
go test ./handler/... # Run handler tests
go test -run TestName ./... # Run single test go test -run TestName ./... # Run single test
go test -v ./handler/... # Verbose output go test -v ./handler/... # Verbose output
``` ```
### Analyzer (analyzer/) ### Analyzer (`analyzer/`)
```bash ```bash
python server.py # Start FastAPI python server.py # Start FastAPI (has `if __name__ == "__main__"`)
uvicorn server:app --reload # Hot reload uvicorn server:app --reload # Requires cwd == analyzer/
pytest # Run all tests pytest # Run all tests
pytest test_jd_cleaner.py # Single test file pytest test_jd_cleaner.py # Single test file
pytest -k "pattern" # Run by pattern
``` ```
### Docker ### Docker
```bash ```bash
docker-compose up -d --build # Start/rebuild all services docker compose up -d --build --remove-orphans # Start/rebuild all
docker-compose logs -f server # Follow logs docker compose logs -f server # Follow logs
docker-compose down # Stop services docker compose down # Stop services
``` ```
## Code Style ## Code Style & Conventions
### General ### TypeScript/Svelte (`web/`)
- **Comments:** Chinese common for business logic; English for technical. - **Formatting:** Prettier (tabs, single quotes, `trailingComma: none`, printWidth 100)
- **Conventions:** Follow existing patterns. Check `package.json`/`go.mod`/`requirements.txt` before adding dependencies. - **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.
### TypeScript/Svelte (web/) ### Go (`server/`)
- **Formatting:** Prettier (tabs, single quotes, printWidth 100) - **Module:** `billai-server` (import path). Use this in `go test` / `go build` when outside the directory.
- **Naming:** `PascalCase` for types/components, `camelCase` for variables
- **Imports:** Use `$lib` alias, `$app/*` for SvelteKit builtins. No relative paths for lib modules.
- **Svelte 5:** Use runes (`$state`, `$derived`, `$effect`, `$props`). Event: `onclick={fn}`.
- **Types:** `export interface` for models. Frontend `camelCase`, API `snake_case`. Converters in `$lib/models/bill.ts`.
- **Error Handling:** Check `response.ok`, throw `Error(\`HTTP ${status}\`)`. On 401: `auth.logout()` + redirect.
- **Auth:** `createAuthStore()` in `$lib/stores/auth.ts`. Token in `localStorage` key `auth`. Use `apiFetch()` in `$lib/api.ts`.
### Go Backend (server/)
- **Layer:** `handler``service``adapter`/`repository``model`. No business logic in handlers. - **Layer:** `handler``service``adapter`/`repository``model`. No business logic in handlers.
- **Struct tags:** JSON `snake_case`, `omitempty` optional. Pointer for optional patch fields. Sensitive: `json:"-"`. - **Struct tags:** JSON `snake_case`, `omitempty` for optional. Pointer types for optional patch fields. Sensitive fields: `json:"-"`.
- **Error handling:** 500 for DB errors, 400 for bad requests, 404 not found. Wrap with `fmt.Errorf("context: %w", err)`. - **Response shapes:**
- **Response:** `Result bool`, `Message`, `Data *T`. Auth: `success bool`, `error`, `data`. - Business APIs: `result bool`, `message string`, `data *T`
- **Time:** Use custom `LocalTime` type (serializes as `2006-01-02 15:04:05`). - Auth APIs: `success bool`, `error string`, `data *T` (and `code` for error types)
- **Soft delete:** Never hard-delete. Filter `is_deleted: false` in queries. - **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 (analyzer/) ### Python (`analyzer/`)
- **Style:** PEP 8. `snake_case` variables, `UPPER_CASE` constants. Prefix private globals with `_`. - **Style:** PEP 8. `snake_case` vars, `UPPER_CASE` constants. Prefix private globals with `_`.
- **Type hints:** Mandatory. Use `Optional[str]` or `str | None`. - **Type hints:** Mandatory. Prefer `str | None` or `Optional[str]`.
- **Models:** `pydantic.BaseModel` for API schemas. - **Models:** `pydantic.BaseModel` for API schemas.
- **Cleaners:** Extend `BaseCleaner(ABC)` from `cleaners/base.py`. Category rules in `config/category.yaml`. - **Cleaners:** Extend `BaseCleaner(ABC)` from `cleaners/base.py`. Category rules in `config/category.yaml`.
## Key Patterns ## Key Patterns & Quirks
### API Flow ### API Flow
``` ```
@@ -87,28 +85,70 @@ Browser → SvelteKit proxy → Go (Gin) → handler → service → adapter →
``` ```
### Authentication ### Authentication
- JWT (HS256). Token in `localStorage` key `auth`. Header: `Authorization: Bearer <token>`. - JWT (HS256). Header: `Authorization: Bearer <token>`.
- `middleware.AuthRequired()` wraps `/api/*` (except `/api/auth/*`). - `middleware.AuthRequired()` guards authed routes. Public routes: `/api/auth/*`, `/api/changelog`, `/health`.
- 401 anywhere`auth.logout()` + redirect `/login`. - Frontend `apiFetch()` intercepts 401`auth.logout()` + redirect `/login`.
### File Processing ### File Processing Pipeline
Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type → Deduplicate → Clean → Save to MongoDB. Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type (alipay/wechat/jd) → Deduplicate against MongoDB → Clean via Python → Save cleaned data.
### 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:
**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.
**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`.
### Adapter (Go ↔ Python) ### Adapter (Go ↔ Python)
`adapter.Cleaner` interface: HTTP (`adapter/http`, default) or subprocess (`adapter/python`). Set via `ANALYZER_MODE` env var. `adapter.Cleaner` interface. Two modes:
- `http` (default): calls FastAPI at `ANALYZER_URL`
- `subprocess`: spawns `python analyzer/clean_bill.py`
Set via `ANALYZER_MODE` env var or `server/config.yaml` `analyzer.mode`.
### Config Precedence
Go backend reads `server/config.yaml`, but Docker compose sets env vars (`ANALYZER_URL`, `MONGO_URI`, `JWT_SECRET`, etc.) that override it.
### SvelteKit Config Notes
- `svelte.config.js` uses `adapter-node` for Docker SSR.
- `csrf.trustedOrigins: ['*']` disables CSRF checks.
- `onwarn` ignores all `a11y_*` warnings (chart components).
### Deployment
- Gitea Actions self-hosted runner (`.gitea/workflows/deploy.yaml`), not GitHub.
- `deploy.sh` is the manual deployment script (same logic as CI).
### 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 ## Important Files
| File | Role | | File | Role |
|---|---| |---|---|
| `web/src/lib/api.ts` | Central API client, auth injection | | `web/src/lib/api.ts` | Central API client, auth injection, all API functions |
| `web/src/lib/stores/auth.ts` | Auth state, JWT handling | | `web/src/lib/stores/auth.ts` | Auth state, JWT handling, localStorage key `auth` |
| `web/src/lib/models/bill.ts` | UIBill model + converters | | `web/src/lib/models/bill.ts` | UIBill model + snake_case ↔ camelCase converters |
| `server/main.go` | Entry point | | `web/src/routes/api/[...path]/+server.ts` | SvelteKit → Go proxy (GET/POST only) |
| `server/handler/upload.go` | Full upload pipeline | | `server/main.go` | Entry point, wires adapter + repository + router |
| `server/handler/bills.go` | List/filter bills | | `server/config.yaml` | Go backend config (Mongo, auth, paths, analyzer mode) |
| `server/model/bill.go` | Bill models, LocalTime type | | `server/router/router.go` | Route table, auth group definitions |
| `server/adapter/adapter.go` | Cleaner interface | | `server/handler/upload.go` | Full upload pipeline handler |
| `server/repository/mongo/repository.go` | MongoDB implementation | | `server/handler/bills.go` | List/filter/update/delete bills |
| `analyzer/server.py` | FastAPI entry | | `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/cleaners/base.py` | BaseCleaner ABC |
| `analyzer/category.py` | Category inference | | `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

@@ -7,7 +7,24 @@
## [Unreleased] ## [Unreleased]
## [1.0.8] - 2026-03-23 ### 新增
- **微信跨批次退款核销** - 重复上传微信账单时,自动识别"当前状态"字段中的退款信息并核销原始支出记录
- 支持「已全额退款」:原支出记录软删除
- 支持「已退款(¥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 文件 - **账单导出 Excel 功能** - 支持将筛选后的账单导出为 Excel 文件
@@ -19,16 +36,23 @@
- **酒店旅游分类** - 新增「酒店旅游」支出分类 - **酒店旅游分类** - 新增「酒店旅游」支出分类
- 涵盖关键词:酒店、宾馆、民宿、客栈、携程、飞猪、去哪儿、同程、旅游、旅行、景区、门票、度假等 - 涵盖关键词:酒店、宾馆、民宿、客栈、携程、飞猪、去哪儿、同程、旅游、旅行、景区、门票、度假等
- 相关关键词从「文化休闲」和「交通出行」中分离,避免分类冲突 - 相关关键词从「文化休闲」和「交通出行」中分离,避免分类冲突
- **动态版本日志系统** - 将版本更新日志从前端硬编码改为动态获取
- 后端新增 `/api/changelog` 公开接口,实时解析 CHANGELOG.md
- 前端ChangelogDrawer组件改为异步加载日志支持加载态和错误处理
- 新增 Markdown 解析器,自动提取版本、日期和分类变更内容
### 技术改进 ### 技术改进
- Go 版本升级到 1.24(支持 excelize 依赖) - Go 版本升级到 1.24(支持 excelize 依赖)
- 新增 `server/handler/export.go` 导出处理器 - 新增 `server/handler/export.go` 导出处理器
- 新增 `web/src/lib/api.ts` 中的 `exportBills()` 函数 - 新增 `web/src/lib/api.ts` 中的 `exportBills()` 函数
- 新增 `server/handler/changelog.go``server/service/changelog.go` 日志解析模块
- 导出 `apiFetch` 函数供公开 API 调用
### 修复 ### 修复
- **各页面账单分类不一致** - 账单列表页和复核页改从 `$lib/data/categories` 统一导入分类列表 - **各页面账单分类不一致** - 账单列表页和复核页改从 `$lib/data/categories` 统一导入分类列表
- 删除两处本地重复硬编码的旧版 13 项分类 - 删除两处本地重复硬编码的旧版 13 项分类
- `BillDetailDrawer``categories` prop 类型改为 `readonly string[]` - `BillDetailDrawer``categories` prop 类型改为 `readonly string[]`
- **前端版本日志显示** - 移除硬编码的 14 个版本数据,改为从 API 动态加载
## [1.3.1] - 2026-01-26 ## [1.3.1] - 2026-01-26

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

View File

@@ -112,8 +112,15 @@ class AlipayCleaner(BaseCleaner):
if key: if key:
if key not in order_refunds: if key not in order_refunds:
order_refunds[key] = Decimal("0") order_refunds[key] = {
order_refunds[key] += refund_amount "amount": Decimal("0"),
"merchant_order_no": refund_merchant_no,
"refund_order_no": refund_order_no,
"time": row[0],
"merchant": row[2],
"description": row[4] if len(row) > 4 else "",
}
order_refunds[key]["amount"] += refund_amount
print(f" 退款记录: {row[0]} | {row[2]} | {refund_amount}") print(f" 退款记录: {row[0]} | {row[2]} | {refund_amount}")
return order_refunds return order_refunds
@@ -121,6 +128,7 @@ class AlipayCleaner(BaseCleaner):
def _process_expenses(self, expense_rows: list, order_refunds: dict) -> list: def _process_expenses(self, expense_rows: list, order_refunds: dict) -> list:
"""处理支出记录""" """处理支出记录"""
final_rows = [] final_rows = []
matched_keys = set()
for row in expense_rows: for row in expense_rows:
if len(row) >= 12: if len(row) >= 12:
@@ -132,13 +140,14 @@ class AlipayCleaner(BaseCleaner):
refund_amount = Decimal("0") refund_amount = Decimal("0")
matched_key = None matched_key = None
for key, amount in order_refunds.items(): for key, refund in order_refunds.items():
if key and (order_no == key or merchant_no == key or order_no.startswith(key)): if key and (order_no == key or merchant_no == key or order_no.startswith(key)):
refund_amount = amount refund_amount = refund["amount"]
matched_key = key matched_key = key
break break
if matched_key: if matched_key:
matched_keys.add(matched_key)
if refund_amount >= expense_amount: if refund_amount >= expense_amount:
# 全额退款,删除 # 全额退款,删除
self.stats["fully_refunded"] += 1 self.stats["fully_refunded"] += 1
@@ -164,6 +173,21 @@ class AlipayCleaner(BaseCleaner):
else: else:
final_rows.append(row) final_rows.append(row)
# 本批次内未匹配到对应支出的退款,交由调用方做跨批次核销
self.unresolved_refunds = [
{
"order_no": key,
"merchant_order_no": refund["merchant_order_no"],
"refund_order_no": refund["refund_order_no"],
"amount": float(format_amount(refund["amount"])),
"time": refund["time"],
"merchant": refund["merchant"],
"description": refund["description"],
}
for key, refund in order_refunds.items()
if key not in matched_keys
]
return final_rows return final_rows
def _is_platform_merchant(self, merchant: str) -> bool: def _is_platform_merchant(self, merchant: str) -> bool:

View File

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

View File

@@ -55,6 +55,22 @@ class WechatCleaner(BaseCleaner):
# 第三步:处理退款(包括转账退款) # 第三步:处理退款(包括转账退款)
final_expense_rows, income_rows = self._process_refunds(expense_rows, income_rows) final_expense_rows, income_rows = self._process_refunds(expense_rows, income_rows)
# 收集跨批次未匹配的 -退款 行(当前批次无对应支出记录,需 Go 侧跨批次核销)
expense_merchants = {exp[2].strip() for exp in expense_rows}
for refund_row in refund_rows:
if refund_row[2].strip() not in expense_merchants:
amount = float(parse_amount(refund_row[5]))
if amount > 0:
self.unresolved_refunds.append({
"order_no": "",
"merchant_order_no": refund_row[9].strip() if len(refund_row) > 9 else "",
"refund_order_no": refund_row[8].strip() if len(refund_row) > 8 else "",
"amount": amount,
"time": refund_row[0],
"merchant": refund_row[2],
"description": refund_row[3] if len(refund_row) > 3 else "",
})
print(f"\n处理结果:") print(f"\n处理结果:")
print(f" 全额退款删除: {self.stats['fully_refunded']}") print(f" 全额退款删除: {self.stats['fully_refunded']}")
print(f" 部分退款调整: {self.stats['partially_refunded']}") print(f" 部分退款调整: {self.stats['partially_refunded']}")

View File

@@ -52,6 +52,7 @@ class CleanResponse(BaseModel):
bill_type: str bill_type: str
message: str message: str
output_path: Optional[str] = None output_path: Optional[str] = None
unresolved_refunds: list[dict] = []
class CategoryRequest(BaseModel): class CategoryRequest(BaseModel):
@@ -138,22 +139,22 @@ def do_clean(
start: str = None, start: str = None,
end: str = None, end: str = None,
output_format: str = "csv" output_format: str = "csv"
) -> tuple[bool, str, str]: ) -> tuple[bool, str, str, list[dict]]:
""" """
执行清洗逻辑 执行清洗逻辑
Returns: Returns:
(success, bill_type, message) (success, bill_type, message, unresolved_refunds)
""" """
# 检查文件是否存在 # 检查文件是否存在
if not Path(input_path).exists(): if not Path(input_path).exists():
return False, "", f"文件不存在: {input_path}" return False, "", f"文件不存在: {input_path}", []
# 检测账单类型 # 检测账单类型
if bill_type == "auto": if bill_type == "auto":
detected_type = detect_bill_type(input_path) detected_type = detect_bill_type(input_path)
if detected_type is None: if detected_type is None:
return False, "", "无法识别账单类型" return False, "", "无法识别账单类型", []
bill_type = detected_type bill_type = detected_type
# 计算日期范围 # 计算日期范围
@@ -172,10 +173,10 @@ def do_clean(
cleaner.clean() cleaner.clean()
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"} type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
return True, bill_type, f"{type_names.get(bill_type, bill_type)}账单清洗完成" return True, bill_type, f"{type_names.get(bill_type, bill_type)}账单清洗完成", cleaner.unresolved_refunds
except Exception as e: except Exception as e:
return False, bill_type, f"清洗失败: {str(e)}" return False, bill_type, f"清洗失败: {str(e)}", []
# ============================================================================= # =============================================================================
@@ -215,7 +216,7 @@ async def clean_bill(request: CleanRequest):
接收账单文件路径,执行清洗后输出到指定路径 接收账单文件路径,执行清洗后输出到指定路径
""" """
success, bill_type, message = do_clean( success, bill_type, message, unresolved_refunds = do_clean(
input_path=request.input_path, input_path=request.input_path,
output_path=request.output_path, output_path=request.output_path,
bill_type=request.bill_type or "auto", bill_type=request.bill_type or "auto",
@@ -233,7 +234,8 @@ async def clean_bill(request: CleanRequest):
success=True, success=True,
bill_type=bill_type, bill_type=bill_type,
message=message, message=message,
output_path=request.output_path output_path=request.output_path,
unresolved_refunds=unresolved_refunds
) )
@@ -264,7 +266,7 @@ async def clean_bill_upload(
output_path = tmp_output.name output_path = tmp_output.name
try: try:
success, detected_type, message = do_clean( success, detected_type, message, unresolved_refunds = do_clean(
input_path=input_path, input_path=input_path,
output_path=output_path, output_path=output_path,
bill_type=bill_type or "auto", bill_type=bill_type or "auto",
@@ -282,7 +284,8 @@ async def clean_bill_upload(
success=True, success=True,
bill_type=detected_type, bill_type=detected_type,
message=message, message=message,
output_path=output_path output_path=output_path,
unresolved_refunds=unresolved_refunds
) )
finally: finally:

View File

@@ -11,7 +11,8 @@ import sys
sys.stdout.reconfigure(encoding='utf-8') sys.stdout.reconfigure(encoding='utf-8')
def test_jd_cleaner(): def test_jd_cleaner():
zip_path = r'D:\Projects\BillAI\mock_data\京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip' base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
zip_path = os.path.join(base_dir, 'mock_data', '京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip')
with zipfile.ZipFile(zip_path, 'r') as zf: with zipfile.ZipFile(zip_path, 'r') as zf:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:

View File

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

View File

@@ -1,5 +1,6 @@
# Go 服务 Dockerfile # Go 服务 Dockerfile
# 多阶段构建:编译阶段 + 运行阶段 # 多阶段构建:编译阶段 + 运行阶段
# 构建上下文项目根目录docker-compose context: .
# ===== 编译阶段 ===== # ===== 编译阶段 =====
FROM golang:1.24-alpine AS builder FROM golang:1.24-alpine AS builder
@@ -10,11 +11,11 @@ WORKDIR /build
ENV GOPROXY=https://goproxy.cn,direct ENV GOPROXY=https://goproxy.cn,direct
# 先复制依赖文件,利用 Docker 缓存 # 先复制依赖文件,利用 Docker 缓存
COPY go.mod go.sum ./ COPY server/go.mod server/go.sum ./
RUN go mod download RUN go mod download
# 复制源代码并编译 # 复制源代码并编译
COPY . . COPY server/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o billai-server . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o billai-server .
# ===== 运行阶段 ===== # ===== 运行阶段 =====
@@ -35,6 +36,9 @@ ENV TZ=Asia/Shanghai
COPY --from=builder /build/billai-server . COPY --from=builder /build/billai-server .
COPY --from=builder /build/config.yaml . COPY --from=builder /build/config.yaml .
# 复制项目根目录的 CHANGELOG.md
COPY CHANGELOG.md .
# 创建必要目录 # 创建必要目录
RUN mkdir -p uploads outputs RUN mkdir -p uploads outputs

View File

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

View File

@@ -29,10 +29,22 @@ type CleanRequest struct {
// CleanResponse HTTP 清洗响应 // CleanResponse HTTP 清洗响应
type CleanResponse struct { type CleanResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
BillType string `json:"bill_type"` BillType string `json:"bill_type"`
Message string `json:"message"` Message string `json:"message"`
OutputPath string `json:"output_path,omitempty"` OutputPath string `json:"output_path,omitempty"`
UnresolvedRefunds []UnresolvedRefund `json:"unresolved_refunds,omitempty"`
}
// UnresolvedRefund 本次清洗未在同批次内匹配到对应支出的退款(与 Python 端 dict 字段对应)
type UnresolvedRefund struct {
OrderNo string `json:"order_no"`
MerchantOrderNo string `json:"merchant_order_no"`
RefundOrderNo string `json:"refund_order_no"`
Amount float64 `json:"amount"`
Time string `json:"time"`
Merchant string `json:"merchant"`
Description string `json:"description"`
} }
// ErrorResponse 错误响应 // ErrorResponse 错误响应
@@ -149,9 +161,23 @@ func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions
} }
} }
unresolvedRefunds := make([]adapter.UnresolvedRefund, 0, len(cleanResp.UnresolvedRefunds))
for _, ur := range cleanResp.UnresolvedRefunds {
unresolvedRefunds = append(unresolvedRefunds, adapter.UnresolvedRefund{
OrderNo: ur.OrderNo,
MerchantOrderNo: ur.MerchantOrderNo,
RefundOrderNo: ur.RefundOrderNo,
Amount: ur.Amount,
Time: ur.Time,
Merchant: ur.Merchant,
Description: ur.Description,
})
}
return &adapter.CleanResult{ return &adapter.CleanResult{
BillType: cleanResp.BillType, BillType: cleanResp.BillType,
Output: cleanResp.Message, Output: cleanResp.Message,
UnresolvedRefunds: unresolvedRefunds,
}, nil }, nil
} }

View File

@@ -5,6 +5,7 @@ go 1.24.0
require ( require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/xuri/excelize/v2 v2.10.1
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9
go.mongodb.org/mongo-driver v1.13.1 go.mongodb.org/mongo-driver v1.13.1
golang.org/x/text v0.34.0 golang.org/x/text v0.34.0
@@ -39,7 +40,6 @@ require (
github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/xuri/efp v0.0.1 // indirect github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.10.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect

View File

@@ -67,9 +67,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 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 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -101,21 +101,18 @@ golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= 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/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
@@ -127,8 +124,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -139,8 +134,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -98,7 +98,11 @@ func Login(c *gin.Context) {
secret := config.Global.JWTSecret secret := config.Global.JWTSecret
if secret == "" { if secret == "" {
secret = "billai-default-secret" c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "服务器 JWT 配置缺失",
})
return
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -144,7 +148,12 @@ func ValidateToken(c *gin.Context) {
secret := config.Global.JWTSecret secret := config.Global.JWTSecret
if secret == "" { if secret == "" {
secret = "billai-default-secret" c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "服务器 JWT 配置缺失",
"code": "TOKEN_INVALID",
})
return
} }
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {

View File

@@ -2,7 +2,6 @@ package handler
import ( import (
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -154,18 +153,6 @@ func ListBills(c *gin.Context) {
}) })
} }
// parsePageParam 解析分页参数
func parsePageParam(s string, defaultVal int) int {
if s == "" {
return defaultVal
}
val, err := strconv.Atoi(s)
if err != nil || val < 1 {
return defaultVal
}
return val
}
// MonthlyStatsResponse 月度统计响应 // MonthlyStatsResponse 月度统计响应
type MonthlyStatsResponse struct { type MonthlyStatsResponse struct {
Result bool `json:"result"` Result bool `json:"result"`
@@ -202,6 +189,13 @@ func MonthlyStats(c *gin.Context) {
// ReviewStats 获取待复核数据统计 // ReviewStats 获取待复核数据统计
func ReviewStats(c *gin.Context) { func ReviewStats(c *gin.Context) {
repo := repository.GetRepository() repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusServiceUnavailable, model.ReviewResponse{
Result: false,
Message: "数据库未连接",
})
return
}
// 从MongoDB查询所有需要复核的账单 // 从MongoDB查询所有需要复核的账单
bills, err := repo.GetBillsNeedReview() bills, err := repo.GetBillsNeedReview()

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

@@ -223,7 +223,7 @@ func Upload(c *gin.Context) {
End: req.End, End: req.End,
Format: req.Format, Format: req.Format,
} }
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts) cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
if cleanErr != nil { if cleanErr != nil {
service.CleanupExtractedFiles(extractedFiles) service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusInternalServerError, model.UploadResponse{ c.JSON(http.StatusInternalServerError, model.UploadResponse{
@@ -255,22 +255,56 @@ func Upload(c *gin.Context) {
} }
service.CleanupExtractedFiles(extractedFiles) service.CleanupExtractedFiles(extractedFiles)
repo := repository.GetRepository()
// 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录 // 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录
var jdRelatedDeleted int64 var jdRelatedDeleted int64
if billType == "jd" { if billType == "jd" && repo != nil {
repo := repository.GetRepository() deleted, err := repo.SoftDeleteJDRelatedBills()
if repo != nil { if err != nil {
deleted, err := repo.SoftDeleteJDRelatedBills() fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err)
if err != nil { } else if deleted > 0 {
fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err) jdRelatedDeleted = deleted
} else if deleted > 0 { fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted)
jdRelatedDeleted = deleted }
fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted) }
// 14. 核销跨批次退款(支付宝:本次清洗中未在同批次内匹配到对应支出的退款)
var reconciledCount int
if repo != nil && cleanResult != nil {
for _, ur := range cleanResult.UnresolvedRefunds {
matched, rErr := repo.ReconcileRefund(billType, ur.OrderNo, ur.MerchantOrderNo, ur.Amount, ur.Time, ur.Merchant, ur.Description, ur.RefundOrderNo)
if rErr != nil {
fmt.Printf("⚠️ 退款核销失败: %v\n", rErr)
continue
}
if matched {
reconciledCount++
fmt.Printf("💰 已核销退款: 订单%s, 金额%.2f元\n", ur.OrderNo, ur.Amount)
} }
} }
} }
// 14. 返回成功响应 // 15. 核销跨批次微信退款(重复上传的行中携带的已退款状态)
if repo != nil && len(dedupResult.WechatRefundUpdates) > 0 {
for _, wu := range dedupResult.WechatRefundUpdates {
matched, rErr := repo.ReconcileWechatRefund(wu.TransactionID, wu.FullRefund, wu.CumulativeRefundAmount)
if rErr != nil {
fmt.Printf("⚠️ 微信退款核销失败: %v\n", rErr)
continue
}
if matched {
reconciledCount++
if wu.FullRefund {
fmt.Printf("💰 已核销微信全额退款: 交易单号%s\n", wu.TransactionID)
} else {
fmt.Printf("💰 已核销微信部分退款: 交易单号%s, 累计退款%.2f元\n", wu.TransactionID, wu.CumulativeRefundAmount)
}
}
}
}
// 16. 返回成功响应
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount) message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
if dedupResult.DuplicateCount > 0 { if dedupResult.DuplicateCount > 0 {
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount) message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
@@ -278,18 +312,22 @@ func Upload(c *gin.Context) {
if jdRelatedDeleted > 0 { if jdRelatedDeleted > 0 {
message = fmt.Sprintf("%s标记删除 %d 条重复的京东订单", message, jdRelatedDeleted) message = fmt.Sprintf("%s标记删除 %d 条重复的京东订单", message, jdRelatedDeleted)
} }
if reconciledCount > 0 {
message = fmt.Sprintf("%s核销退款 %d 条", message, reconciledCount)
}
c.JSON(http.StatusOK, model.UploadResponse{ c.JSON(http.StatusOK, model.UploadResponse{
Result: true, Result: true,
Message: message, Message: message,
Data: &model.UploadData{ Data: &model.UploadData{
BillType: billType, BillType: billType,
FileURL: fmt.Sprintf("/download/%s", outputFileName), FileURL: fmt.Sprintf("/download/%s", outputFileName),
FileName: outputFileName, FileName: outputFileName,
RawCount: rawCount, RawCount: rawCount,
CleanedCount: cleanedCount, CleanedCount: cleanedCount,
DuplicateCount: dedupResult.DuplicateCount, DuplicateCount: dedupResult.DuplicateCount,
JDRelatedDeleted: jdRelatedDeleted, JDRelatedDeleted: jdRelatedDeleted,
ReconciledRefundCount: reconciledCount,
}, },
}) })
} }

View File

@@ -38,7 +38,13 @@ func AuthRequired() gin.HandlerFunc {
secret := config.Global.JWTSecret secret := config.Global.JWTSecret
if secret == "" { if secret == "" {
secret = "billai-default-secret" c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "服务器 JWT 配置缺失",
"code": "TOKEN_INVALID",
})
c.Abort()
return
} }
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {

View File

@@ -88,9 +88,10 @@ type CleanedBill struct {
Category string `bson:"category" json:"category"` // 交易分类 Category string `bson:"category" json:"category"` // 交易分类
Merchant string `bson:"merchant" json:"merchant"` // 交易对方 Merchant string `bson:"merchant" json:"merchant"` // 交易对方
Description string `bson:"description" json:"description"` // 商品说明 Description string `bson:"description" json:"description"` // 商品说明
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支 IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
Amount float64 `bson:"amount" json:"amount"` // 金额 Amount float64 `bson:"amount" json:"amount"` // 金额
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式 OriginalAmount float64 `bson:"original_amount,omitempty" json:"original_amount,omitempty"` // 原始金额(入库时),用于微信跨批次退款核销
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
Status string `bson:"status" json:"status"` // 交易状态 Status string `bson:"status" json:"status"` // 交易状态
Remark string `bson:"remark" json:"remark"` // 备注 Remark string `bson:"remark" json:"remark"` // 备注
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空 ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空

View File

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

View File

@@ -4,6 +4,7 @@ package mongo
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"time" "time"
"go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson"
@@ -16,6 +17,9 @@ import (
"billai-server/repository" "billai-server/repository"
) )
// refundEpsilon 退款核销后剩余金额的容差阈值,小于该值视为已全额退款
const refundEpsilon = 0.005
// Repository MongoDB 账单存储实现 // Repository MongoDB 账单存储实现
type Repository struct { type Repository struct {
client *mongo.Client client *mongo.Client
@@ -498,6 +502,149 @@ func (r *Repository) SoftDeleteJDRelatedBills() (int64, error) {
return result.ModifiedCount, nil return result.ModifiedCount, nil
} }
// ReconcileRefund 将跨批次退款核销到已存储的清洗后账单
// 按 bill_type + (transaction_id == orderNo 或 merchant_order_no == merchantOrderNo) 查找未删除记录
// 全额退款(剩余金额 <= refundEpsilon则软删除部分退款则扣减 amount 并追加备注
func (r *Repository) ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (bool, error) {
if r.cleanedCollection == nil {
return false, fmt.Errorf("cleaned collection not initialized")
}
if orderNo == "" && merchantOrderNo == "" {
return false, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var or []bson.M
if orderNo != "" {
or = append(or, bson.M{"transaction_id": orderNo})
}
if merchantOrderNo != "" {
or = append(or, bson.M{"merchant_order_no": merchantOrderNo})
}
filter := bson.M{
"bill_type": billType,
"is_deleted": bson.M{"$ne": true},
"$or": or,
}
var bill model.CleanedBill
if err := r.cleanedCollection.FindOne(ctx, filter).Decode(&bill); err != nil {
if err == mongo.ErrNoDocuments {
return false, nil
}
return false, fmt.Errorf("查询待核销账单失败: %w", err)
}
remaining := bill.Amount - refundAmount
now := time.Now()
if remaining <= refundEpsilon {
update := bson.M{"$set": bson.M{
"is_deleted": true,
"updated_at": now,
"remark": fmt.Sprintf("[退款核销]全额退款%.2f元(退款单号%s);%s", refundAmount, refundOrderNo, bill.Remark),
}}
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
if err != nil {
return false, fmt.Errorf("核销退款失败: %w", err)
}
return true, nil
}
remaining = math.Round(remaining*100) / 100
update := bson.M{"$set": bson.M{
"amount": remaining,
"updated_at": now,
"remark": fmt.Sprintf("原金额%.2f元,退款%.2f元(退款单号%s);%s", bill.Amount, refundAmount, refundOrderNo, bill.Remark),
}}
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
if err != nil {
return false, fmt.Errorf("核销退款失败: %w", err)
}
return true, nil
}
// ReconcileWechatRefund 将跨批次微信退款核销到已存储的清洗后账单
// 微信"当前状态"字段为累计退款金额,使用 original_amount入库时原始金额计算剩余
func (r *Repository) ReconcileWechatRefund(transactionID string, fullRefund bool, cumulativeRefundAmount float64) (bool, error) {
if r.cleanedCollection == nil {
return false, fmt.Errorf("cleaned collection not initialized")
}
if transactionID == "" {
return false, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := bson.M{
"bill_type": "wechat",
"transaction_id": transactionID,
"is_deleted": bson.M{"$ne": true},
}
var bill model.CleanedBill
if err := r.cleanedCollection.FindOne(ctx, filter).Decode(&bill); err != nil {
if err == mongo.ErrNoDocuments {
return false, nil
}
return false, fmt.Errorf("查询待核销微信账单失败: %w", err)
}
now := time.Now()
if fullRefund {
update := bson.M{"$set": bson.M{
"is_deleted": true,
"updated_at": now,
"remark": fmt.Sprintf("[退款核销]全额退款;%s", bill.Remark),
}}
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
if err != nil {
return false, fmt.Errorf("核销微信全额退款失败: %w", err)
}
return true, nil
}
// 部分退款用原始金额original_amount减去累计退款金额得到剩余
originalAmount := bill.OriginalAmount
if originalAmount <= 0 {
originalAmount = bill.Amount // 兼容旧记录(无 original_amount 字段)
}
remaining := originalAmount - cumulativeRefundAmount
if remaining <= refundEpsilon {
update := bson.M{"$set": bson.M{
"is_deleted": true,
"updated_at": now,
"remark": fmt.Sprintf("[退款核销]全额退款%.2f元(原金额%.2f元);%s", cumulativeRefundAmount, originalAmount, bill.Remark),
}}
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
if err != nil {
return false, fmt.Errorf("核销微信退款失败: %w", err)
}
return true, nil
}
remaining = math.Round(remaining*100) / 100
update := bson.M{"$set": bson.M{
"amount": remaining,
"updated_at": now,
"remark": fmt.Sprintf("原金额%.2f元,已退款%.2f元;%s", originalAmount, cumulativeRefundAmount, bill.Remark),
}}
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
if err != nil {
return false, fmt.Errorf("核销微信退款失败: %w", err)
}
return true, nil
}
// 建议: 为提升查询性能,可为 bills_cleaned 添加索引
// {transaction_id:1, bill_type:1} 和 {merchant_order_no:1, bill_type:1}
// GetClient 获取 MongoDB 客户端(用于兼容旧代码) // GetClient 获取 MongoDB 客户端(用于兼容旧代码)
func (r *Repository) GetClient() *mongo.Client { func (r *Repository) GetClient() *mongo.Client {
return r.client return r.client

View File

@@ -56,4 +56,16 @@ type BillRepository interface {
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算 // 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
// 返回: 删除数量、错误 // 返回: 删除数量、错误
SoftDeleteJDRelatedBills() (int64, error) SoftDeleteJDRelatedBills() (int64, error)
// ReconcileRefund 将跨批次退款核销到已存储的清洗后账单
// 按 bill_type + (transaction_id == orderNo 或 merchant_order_no == merchantOrderNo) 查找未删除记录
// 全额退款(剩余金额 <= 0.005)则软删除;部分退款则扣减 amount 并追加备注
// 返回: 是否找到并核销了匹配记录、错误(未找到匹配记录不算错误,返回 matched=false
ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (matched bool, err error)
// ReconcileWechatRefund 将跨批次微信退款核销到已存储的清洗后账单
// 微信退款通过重复上传时"当前状态"字段携带退款信息来触发
// fullRefund=true 时软删除原记录;否则用 original_amount - cumulativeRefundAmount 计算剩余金额
// 返回: 是否找到并核销了匹配记录、错误
ReconcileWechatRefund(transactionID string, fullRefund bool, cumulativeRefundAmount float64) (matched bool, err error)
} }

View File

@@ -46,6 +46,9 @@ func setupAPIRoutes(r *gin.Engine) {
api.POST("/auth/login", handler.Login) api.POST("/auth/login", handler.Login)
api.GET("/auth/validate", handler.ValidateToken) api.GET("/auth/validate", handler.ValidateToken)
// 公开接口(无需登录)
api.GET("/changelog", handler.GetChangelog)
// 需要登录的 API // 需要登录的 API
authed := api.Group("/") authed := api.Group("/")
authed.Use(middleware.AuthRequired()) authed.Use(middleware.AuthRequired())

View File

@@ -23,13 +23,21 @@ func getRepo() repository.BillRepository {
return repository.GetRepository() return repository.GetRepository()
} }
// WechatRefundUpdate 微信重复行中携带的退款信息(用于跨批次退款核销)
type WechatRefundUpdate struct {
TransactionID string // 原消费行的交易单号
FullRefund bool // 是否全额退款(已全额退款)
CumulativeRefundAmount float64 // 累计退款金额(已退款(¥X)中的 X与原始金额相减得剩余
}
// DeduplicateResult 去重结果 // DeduplicateResult 去重结果
type DeduplicateResult struct { type DeduplicateResult struct {
OriginalCount int // 原始记录数 OriginalCount int // 原始记录数
DuplicateCount int // 重复记录数 DuplicateCount int // 重复记录数
NewCount int // 新记录数 NewCount int // 新记录数
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件) DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
BillType string // 检测到的账单类型 BillType string // 检测到的账单类型
WechatRefundUpdates []WechatRefundUpdate // 微信重复行中检测到的退款状态(用于跨批次核销)
} }
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径 // DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
@@ -75,6 +83,17 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
return result, nil return result, nil
} }
// 对于微信账单,找到"当前状态"列的索引,用于检测退款状态
wechatStatusIdx := -1
if billType == "wechat" {
for i, col := range header {
if col == "当前状态" {
wechatStatusIdx = i
break
}
}
}
// 检查每行是否重复 // 检查每行是否重复
var newRows [][]string var newRows [][]string
for _, row := range dataRows { for _, row := range dataRows {
@@ -101,6 +120,24 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
newRows = append(newRows, row) newRows = append(newRows, row)
} else { } else {
result.DuplicateCount++ result.DuplicateCount++
// 微信账单:检查重复行是否携带退款状态(跨批次退款核销)
if wechatStatusIdx >= 0 && len(row) > wechatStatusIdx {
status := strings.TrimSpace(row[wechatStatusIdx])
if strings.Contains(status, "已全额退款") {
result.WechatRefundUpdates = append(result.WechatRefundUpdates, WechatRefundUpdate{
TransactionID: transactionID,
FullRefund: true,
})
} else if strings.Contains(status, "已退款") {
if amount := extractWechatRefundAmount(status); amount > 0 {
result.WechatRefundUpdates = append(result.WechatRefundUpdates, WechatRefundUpdate{
TransactionID: transactionID,
FullRefund: false,
CumulativeRefundAmount: amount,
})
}
}
}
} }
} }
@@ -337,6 +374,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
bill.ReviewLevel = row[idx] bill.ReviewLevel = row[idx]
} }
bill.OriginalAmount = bill.Amount
bills = append(bills, bill) bills = append(bills, bill)
} }
@@ -431,6 +469,7 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string
bill.ReviewLevel = v bill.ReviewLevel = v
} }
bill.OriginalAmount = bill.Amount
bills = append(bills, bill) bills = append(bills, bill)
} }
@@ -512,3 +551,28 @@ func parseAmount(s string) float64 {
} }
return 0 return 0
} }
// extractWechatRefundAmount 从微信"当前状态"字段中提取累计退款金额
// 支持格式: "已退款(¥3.00)"、"已退款(¥3.00)"、"已退款¥3.00"
func extractWechatRefundAmount(status string) float64 {
start := strings.Index(status, "(")
end := strings.LastIndex(status, ")")
var inner string
if start >= 0 && end > start {
inner = status[start+1 : end]
} else {
idx := strings.Index(status, "已退款")
if idx < 0 {
return 0
}
inner = status[idx+len("已退款"):]
}
inner = strings.TrimPrefix(inner, "¥")
inner = strings.TrimPrefix(inner, "¥")
inner = strings.TrimSpace(inner)
v, err := strconv.ParseFloat(inner, 64)
if err != nil {
return 0
}
return v
}

128
server/service/changelog.go Normal file
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

@@ -5,7 +5,7 @@ import type { UIBill } from '$lib/models/bill';
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端 // API 配置 - 使用相对路径,由 SvelteKit 代理到后端
const API_BASE = ''; const API_BASE = '';
async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) { export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
if (browser) { if (browser) {

View File

@@ -3,77 +3,48 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Calendar from '@lucide/svelte/icons/calendar'; import Calendar from '@lucide/svelte/icons/calendar';
import Tag from '@lucide/svelte/icons/tag'; import Tag from '@lucide/svelte/icons/tag';
import { onMount } from 'svelte';
import { apiFetch } from '$lib/api';
let { open = $bindable(false) } = $props(); let { open = $bindable(false) } = $props();
// Changelog 内容(从 CHANGELOG.md 解析或硬编码) interface ChangelogEntry {
const changelog = [ version: string;
{ date: string;
version: '1.3.1', changes: Record<string, string[]>;
date: '2026-01-26', }
changes: {
优化: [ let changelog = $state<ChangelogEntry[]>([]);
'版本号显示优化 - 侧边栏版本号按钮样式改进', let isLoading = $state(false);
'移至次级导航区域,与其他菜单项样式一致', let error = $state<string | null>(null);
'更新日志改用 Sheet 组件(右侧滑出),替代底部 Drawer',
'统一暗色主题下的视觉效果' // 获取更新日志
] 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)) {
version: '1.3.0', changelog = data.data;
date: '2026-01-26', } else {
changes: { throw new Error('Invalid response format');
新增: [
'京东账单支持 - 支持京东白条账单上传和清洗',
'自动识别京东账单类型(交易流水 ZIP',
'解析京东白条账单 CSV 格式(含还款日期信息)',
'京东专属分类映射配置',
'支持京东外卖、京东平台商户等商户识别',
'上传页面和账单列表页面添加"京东"选项'
],
优化: [
'京东订单智能去重 - 上传京东账单时自动软删除其他来源中的京东订单',
'分类推断复核等级优化 - 京东账单引入 LOW 复核等级',
'京东平台商户关键词扩展'
],
技术改进: [
'新增京东账单清理器',
'新增京东专属配置',
'后端新增软删除接口',
'新增单元测试11 个测试用例)'
]
}
},
{
version: '1.2.1',
date: '2026-01-23',
changes: {
优化: [
'智能复核快捷确认 - 在复核列表每行添加快捷确认按钮',
'无需打开详情页面即可确认分类正确',
'自动更新统计数据',
'提升复核效率,支持快速批量确认'
],
文档: ['AGENTS.md 更新 - 精简为 150 行,专为 AI 编程助手设计']
}
},
{
version: '1.2.0',
date: '2026-01-25',
changes: {
新增: [
'账单删除功能 - 支持在账单详情抽屉中删除账单(软删除)',
'删除按钮带二次确认,防止误操作',
'已删除的账单在所有查询中自动过滤'
],
技术改进: [
'后端 MongoDB 查询方法添加软删除过滤',
'新增 DELETE /api/bills/:id 接口'
]
} }
} 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> </script>
<Sheet.Root bind:open> <Sheet.Root bind:open>
@@ -85,7 +56,20 @@
</Sheet.Description> </Sheet.Description>
</Sheet.Header> </Sheet.Header>
<div class="flex-1 overflow-y-auto py-6"> <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"> <div class="space-y-8">
{#each changelog as release} {#each changelog as release}
<div class="space-y-3"> <div class="space-y-3">
@@ -120,7 +104,8 @@
</div> </div>
{/each} {/each}
</div> </div>
</div> {/if}
</div>
<Sheet.Footer class="border-t pt-4"> <Sheet.Footer class="border-t pt-4">
<Button variant="outline" onclick={() => (open = false)} class="w-full">关闭</Button> <Button variant="outline" onclick={() => (open = false)} class="w-full">关闭</Button>

View File

@@ -263,7 +263,7 @@
{:else} {:else}
<div> <div>
<div class="text-center mb-6"> <div class="text-center mb-6">
<div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}"> <div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : record.incomeExpense === '不计收支' ? 'text-muted-foreground' : 'text-red-600 dark:text-red-400'}">
¥{record.amount.toFixed(2)} ¥{record.amount.toFixed(2)}
</div> </div>
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div> <div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>

View File

@@ -199,6 +199,20 @@
dayData.set(category, (dayData.get(category) || 0) + amount); dayData.set(category, (dayData.get(category) || 0) + amount);
}); });
// 填充缺失的日期(零支出日期)
const now = new Date();
const allDates: string[] = [];
const cursor = new Date(cutoffDate);
while (cursor <= now) {
allDates.push(formatLocalDate(cursor));
cursor.setDate(cursor.getDate() + 1);
}
allDates.forEach(dateStr => {
if (!dailyMap.has(dateStr)) {
dailyMap.set(dateStr, new Map());
}
});
const dayCount = dailyMap.size; const dayCount = dailyMap.size;
// 根据天数决定聚合粒度 // 根据天数决定聚合粒度