feat: 添加 /api/review-stats 端点和仪表盘实时数据集成

This commit is contained in:
clz
2026-01-10 21:18:38 +08:00
parent 06f6c847d8
commit 6374f55aa1
5 changed files with 668 additions and 18 deletions

487
server/README.md Normal file
View File

@@ -0,0 +1,487 @@
# BillAI 服务器 API 文档
## 概述
BillAI 服务器是一个基于 Go 的后端服务,用于处理账单的上传、清洗、分析和存储。提供 REST API 接口供前端调用。
**服务地址**: `http://localhost:8080`
---
## API 接口列表
### 1. 健康检查
**端点**: `GET /health`
**功能**: 检查服务器健康状态
**参数**: 无
**响应示例**:
```json
{
"status": "ok",
"version": "1.0.0"
}
```
---
## 账单管理 API
### 2. 上传账单文件
**端点**: `POST /api/upload`
**功能**: 上传支付宝或微信账单文件进行清洗和分析
**请求方式**: `multipart/form-data`
**请求参数**:
| 参数名 | 类型 | 必需 | 说明 |
|-------|------|------|------|
| file | File | ✓ | 账单文件CSV格式 |
| type | string | ✓ | 账单类型:`alipay``wechat` |
| format | string | - | 输出格式:`csv``json`(默认: csv |
| year | number | - | 账单年份(用于数据验证) |
| month | number | - | 账单月份(用于数据验证) |
**响应示例** (成功):
```json
{
"result": true,
"message": "账单处理完成",
"data": {
"bill_type": "alipay",
"raw_count": 50,
"cleaned_count": 48,
"duplicate_count": 2,
"needs_review_count": 5,
"file_name": "20260110_150405_alipay_1.csv"
}
}
```
**响应示例** (全部重复):
```json
{
"result": true,
"message": "文件中的 50 条记录全部已存在,无需重复导入",
"data": {
"bill_type": "alipay",
"raw_count": 0,
"cleaned_count": 0,
"duplicate_count": 50
}
}
```
**响应示例** (失败):
```json
{
"result": false,
"message": "账单类型无效,仅支持 alipay 或 wechat"
}
```
---
### 3. 获取账单列表
**端点**: `GET /api/bills`
**功能**: 获取清洗后的账单数据,支持分页、筛选和排序
**请求参数** (Query):
| 参数名 | 类型 | 必需 | 说明 |
|-------|------|------|------|
| page | number | - | 页码从1开始默认: 1 |
| page_size | number | - | 每页数量(默认: 20最大: 100 |
| start_date | string | - | 开始日期YYYY-MM-DD 格式) |
| end_date | string | - | 结束日期YYYY-MM-DD 格式) |
| category | string | - | 交易分类筛选 |
| type | string | - | 账单来源:`alipay``wechat``manual` |
| income_expense | string | - | 收支类型:`收入``支出` |
**响应示例**:
```json
{
"result": true,
"data": {
"total": 150,
"page": 1,
"page_size": 20,
"pages": 8,
"total_expense": 5250.50,
"total_income": 8000.00,
"bills": [
{
"_id": "507f1f77bcf86cd799439011",
"bill_type": "alipay",
"transaction_id": "2021123456789",
"merchant_order_no": "2021123456",
"time": "2026-01-10T10:30:00Z",
"category": "餐饮美食",
"merchant": "星巴克",
"description": "咖啡",
"income_expense": "支出",
"amount": 28.00,
"pay_method": "支付宝",
"status": "交易成功",
"remark": "员工补贴",
"review_level": "",
"created_at": "2026-01-10T10:30:00Z",
"updated_at": "2026-01-10T10:30:00Z",
"source_file": "20260110_150405_alipay_1.csv",
"upload_batch": "20260110_150405"
}
]
}
}
```
**错误响应**:
```json
{
"result": false,
"message": "数据库未连接"
}
```
---
### 4. 手动创建账单
**端点**: `POST /api/bills/manual`
**功能**: 手动添加单条或批量账单,支持去重
**请求方式**: `application/json`
**请求体**:
```json
{
"bills": [
{
"time": "2026-01-10 14:30:00",
"category": "餐饮美食",
"merchant": "测试餐厅",
"description": "午餐",
"income_expense": "支出",
"amount": 50.00,
"pay_method": "支付宝",
"status": "交易成功",
"remark": "测试账单"
}
]
}
```
**请求参数说明**:
| 参数名 | 类型 | 必需 | 说明 |
|-------|------|------|------|
| bills | Array | ✓ | 账单数组 |
| bills[].time | string | ✓ | 交易时间(格式: `YYYY-MM-DD HH:mm:ss` |
| bills[].category | string | ✓ | 交易分类 |
| bills[].income_expense | string | ✓ | 收支类型:`收入``支出` |
| bills[].amount | number | ✓ | 金额(>0 |
| bills[].merchant | string | - | 交易对方(可选) |
| bills[].description | string | - | 商品说明 |
| bills[].pay_method | string | - | 支付方式 |
| bills[].status | string | - | 交易状态(默认: `交易成功` |
| bills[].remark | string | - | 备注 |
**响应示例** (成功):
```json
{
"result": true,
"message": "创建成功",
"data": {
"success": 1,
"failed": 0,
"duplicates": 0
}
}
```
**响应示例** (部分失败):
```json
{
"result": true,
"message": "创建成功",
"data": {
"success": 3,
"failed": 1,
"duplicates": 0
}
}
```
**错误响应**:
```json
{
"result": false,
"message": "时间格式错误: 2026-01-10"
}
```
---
### 5. 获取需要复核的记录
**端点**: `GET /api/review`
**功能**: 获取清洗过程中标记为需要人工复核的记录
**请求参数** (Query):
| 参数名 | 类型 | 必需 | 说明 |
|-------|------|------|------|
| file | string | ✓ | 输出文件名(来自上传接口返回值) |
| page | number | - | 页码(默认: 1 |
| page_size | number | - | 每页数量(默认: 20 |
**响应示例**:
```json
{
"result": true,
"data": {
"total": 5,
"page": 1,
"page_size": 20,
"high_count": 2,
"low_count": 3,
"records": [
{
"row": 15,
"time": "2026-01-10 10:30:00",
"merchant": "未知商户",
"amount": 100.00,
"category": "未分类",
"review_level": "HIGH",
"issue": "无法识别的商户名称",
"suggestion": "请手动确认或修改商户名称"
}
]
}
}
```
---
### 6. 获取月度统计数据
**端点**: `GET /api/monthly-stats`
**功能**: 获取每个月的收入、支出统计(不受日期筛选影响,返回全部月份数据)
**请求参数**: 无
**响应示例**:
```json
{
"result": true,
"data": [
{
"month": "2025-12",
"expense": 8234.50,
"income": 15000.00
},
{
"month": "2026-01",
"expense": 5250.50,
"income": 8000.00
}
]
}
```
**错误响应**:
```json
{
"result": false,
"message": "数据库未连接"
}
```
---
## 数据模型
### 账单数据 (CleanedBill)
```go
type CleanedBill struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"_id"`
BillType string `bson:"bill_type" json:"bill_type"` // alipay/wechat/manual
TransactionID string `bson:"transaction_id" json:"transaction_id"`
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"`
Time time.Time `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"`
Status string `bson:"status" json:"status"`
Remark string `bson:"remark" json:"remark"`
ReviewLevel string `bson:"review_level" json:"review_level"` // HIGH/LOW/""
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
SourceFile string `bson:"source_file" json:"source_file"`
UploadBatch string `bson:"upload_batch" json:"upload_batch"`
}
```
### 月度统计 (MonthlyStat)
```go
type MonthlyStat struct {
Month string `bson:"month" json:"month"` // YYYY-MM
Expense float64 `bson:"expense" json:"expense"` // 支出总额
Income float64 `bson:"income" json:"income"` // 收入总额
}
```
---
## 错误处理
所有 API 响应都遵循以下格式:
```json
{
"result": boolean,
"message": "错误或成功消息",
"data": {}
}
```
### 常见错误码
| HTTP 状态码 | result | 说明 |
|-----------|--------|------|
| 200 | true | 请求成功 |
| 200 | false | 业务逻辑错误(如参数不合法) |
| 400 | false | 请求参数错误 |
| 404 | false | 资源不存在 |
| 500 | false | 服务器内部错误 |
---
## 时区说明
- **存储**: 所有时间在数据库中按 **UTC 时区** 存储ISO 8601 格式)
- **输入**: 手动添加账单时,时间按 **本地时区** 解析(使用 `time.ParseInLocation`
- **输出**: API 返回的时间均为 ISO 8601 UTC 格式
### 时间格式
- 手动创建账单时: `YYYY-MM-DD HH:mm:ss`(本地时间)
- 日期筛选参数: `YYYY-MM-DD`(本地日期)
- API 返回时间: ISO 8601 UTC 格式(如 `2026-01-10T10:30:00Z`
---
## 使用示例
### 上传账单文件
```bash
curl -X POST http://localhost:8080/api/upload \
-F "file=@statement.csv" \
-F "type=alipay" \
-F "format=csv"
```
### 查询账单列表
```bash
curl "http://localhost:8080/api/bills?page=1&page_size=20&start_date=2026-01-01&end_date=2026-01-10&type=alipay"
```
### 手动添加账单
```bash
curl -X POST http://localhost:8080/api/bills/manual \
-H "Content-Type: application/json" \
-d '{
"bills": [
{
"time": "2026-01-10 14:30:00",
"category": "餐饮美食",
"merchant": "星巴克",
"income_expense": "支出",
"amount": 28.00,
"pay_method": "支付宝"
}
]
}'
```
### 获取月度统计
```bash
curl http://localhost:8080/api/monthly-stats
```
---
## 配置
服务器配置文件位于 `config.yaml`,主要配置项:
```yaml
server:
port: "8080"
mongodb:
uri: "mongodb://admin:password@localhost:27017"
database: "billai"
collections:
raw: "bills_raw"
cleaned: "bills_cleaned"
analyzer:
mode: "http" # http 或 process
url: "http://analyzer:8001"
```
---
## 开发信息
### 目录结构
```
server/
├── main.go # 程序入口
├── router/ # 路由配置
├── handler/ # 请求处理器
├── service/ # 业务逻辑
├── repository/ # 数据访问层
├── database/ # 数据库连接
├── model/ # 数据模型
├── adapter/ # 外部服务适配器
├── config/ # 配置管理
└── README.md # 本文件
```
### 主要依赖
- **Gin**: Web 框架
- **MongoDB Driver**: 数据库驱动
- **Go 1.21**: 运行环境
---
## 版本历史
| 版本 | 日期 | 说明 |
|-----|------|------|
| 1.0.0 | 2026-01-10 | 初始版本,支持账单上传、查询、手动添加和统计 |

View File

@@ -198,3 +198,43 @@ func MonthlyStats(c *gin.Context) {
Data: stats,
})
}
// ReviewStats 获取待复核数据统计
func ReviewStats(c *gin.Context) {
repo := repository.GetRepository()
// 从MongoDB查询所有需要复核的账单
bills, err := repo.GetBillsNeedReview()
if err != nil {
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
Result: false,
Message: "查询失败: " + err.Error(),
})
return
}
highCount := 0
lowCount := 0
totalCount := 0
// 统计各等级的复核记录
for _, bill := range bills {
totalCount++
if bill.ReviewLevel == "HIGH" {
highCount++
} else if bill.ReviewLevel == "LOW" {
lowCount++
}
}
c.JSON(http.StatusOK, model.ReviewResponse{
Result: true,
Message: "获取成功",
Data: &model.ReviewData{
Total: totalCount,
High: highCount,
Low: lowCount,
Records: nil, // 统计接口不返回具体记录
},
})
}

View File

@@ -55,5 +55,8 @@ func setupAPIRoutes(r *gin.Engine) {
// 月度统计(全部数据)
api.GET("/monthly-stats", handler.MonthlyStats)
// 待复核数据统计
api.GET("/review-stats", handler.ReviewStats)
}
}

View File

@@ -318,3 +318,14 @@ export async function createManualBills(bills: ManualBillInput[]): Promise<Creat
return response.json();
}
// 获取待复核数据统计
export async function fetchReviewStats(): Promise<ReviewResponse> {
const response = await fetch(`${API_BASE}/api/review-stats`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { uploadBill, type UploadResponse, type BillType } from '$lib/api';
import { onMount } from 'svelte';
import { uploadBill, fetchMonthlyStats, fetchReviewStats, fetchBills, type UploadResponse, type BillType } from '$lib/api';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
@@ -23,37 +24,145 @@
let uploadResult: UploadResponse | null = $state(null);
let errorMessage = $state('');
// 模拟统计数据
const stats = [
// 实时统计数据
let stats = $state([
{
title: '本月支出',
value: 12,580.00',
change: '+12.5%',
trend: 'up',
description: '较上月增加'
value: '¥0.00',
change: '+0%',
trend: 'up' as const,
description: '加载中...'
},
{
title: '本月收入',
value: 25,000.00',
change: '+8.2%',
trend: 'up',
description: '较上月增加'
value: '¥0.00',
change: '+0%',
trend: 'up' as const,
description: '加载中...'
},
{
title: '待复核',
value: '23',
change: '-15%',
trend: 'down',
value: '0',
change: '+0%',
trend: 'up' as const,
description: '需要人工确认'
},
{
title: '已处理账单',
value: '156',
change: '+25%',
trend: 'up',
value: '0',
change: '+0%',
trend: 'up' as const,
description: '累计处理记录'
},
];
]);
// 加载真实统计数据
async function loadStats() {
try {
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
// 计算上月
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const previousMonth = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}`;
console.log('Current month:', currentMonth);
console.log('Previous month:', previousMonth);
// 获取月度统计数据
const monthlyResponse = await fetchMonthlyStats();
console.log('Monthly response:', monthlyResponse);
const monthlyStats = monthlyResponse.data || [];
console.log('Monthly stats:', monthlyStats);
// 获取待复核统计
const reviewResponse = await fetchReviewStats();
console.log('Review response:', reviewResponse);
const reviewTotal = reviewResponse.data?.total || 0;
console.log('Review total:', reviewTotal);
// 获取已处理账单数量
const billsResponse = await fetchBills({ page_size: 1 });
console.log('Bills response:', billsResponse);
const billTotal = billsResponse.data?.total || 0;
console.log('Bill total:', billTotal);
// 提取当月和上月的数据
const currentData = monthlyStats.find(m => m.month === currentMonth);
const previousData = monthlyStats.find(m => m.month === previousMonth);
console.log('Current data:', currentData);
console.log('Previous data:', previousData);
// 计算支出变化百分比
const currentExpense = currentData?.expense || 0;
const previousExpense = previousData?.expense || 0;
const expenseChange = previousExpense > 0
? ((currentExpense - previousExpense) / previousExpense * 100).toFixed(1)
: 0;
const expenseTrend = parseFloat(expenseChange.toString()) >= 0 ? 'up' : 'down';
// 计算收入变化百分比
const currentIncome = currentData?.income || 0;
const previousIncome = previousData?.income || 0;
const incomeChange = previousIncome > 0
? ((currentIncome - previousIncome) / previousIncome * 100).toFixed(1)
: 0;
const incomeTrend = parseFloat(incomeChange.toString()) >= 0 ? 'up' : 'down';
// 格式化金额
const formatAmount = (amount: number) => {
return ${amount.toFixed(2)}`;
};
const formatChange = (change: number | string) => {
const changeNum = typeof change === 'string' ? parseFloat(change) : change;
const sign = changeNum >= 0 ? '+' : '';
return `${sign}${changeNum.toFixed(1)}%`;
};
const newStats = [
{
title: '本月支出',
value: formatAmount(currentExpense),
change: formatChange(expenseChange),
trend: expenseTrend,
description: '较上月' + (expenseTrend === 'up' ? '增加' : '减少')
},
{
title: '本月收入',
value: formatAmount(currentIncome),
change: formatChange(incomeChange),
trend: incomeTrend,
description: '较上月' + (incomeTrend === 'up' ? '增加' : '减少')
},
{
title: '待复核',
value: reviewTotal.toString(),
change: '+0%',
trend: 'up' as const,
description: '需要人工确认'
},
{
title: '已处理账单',
value: billTotal.toString(),
change: '+0%',
trend: 'up' as const,
description: '累计处理记录'
},
];
console.log('New stats:', newStats);
stats = newStats;
} catch (err) {
console.error('Failed to load stats:', err);
// 保持默认状态
}
}
onMount(() => {
loadStats();
});
function handleDragOver(e: DragEvent) {
e.preventDefault();