diff --git a/README.md b/README.md index 8b4885c..aff8786 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。 -![版本](https://img.shields.io/badge/版本-1.0.1-green) +![版本](https://img.shields.io/badge/版本-1.0.2-green) ![架构](https://img.shields.io/badge/架构-微服务-blue) ![Go](https://img.shields.io/badge/Go-1.21-00ADD8) ![Python](https://img.shields.io/badge/Python-3.12-3776AB) @@ -260,6 +260,13 @@ python server.py ## 📋 版本历史 +### v1.0.2 (2026-01-11) + +- 🐛 修复账单时间显示为 UTC 时区的问题,现在正确显示本地时间 +- 🐛 修复微信账单金额解析问题(半角¥符号) +- ✨ 新增月度统计 API,支持获取所有月份的收支数据 +- 🔧 优化数据分析页面月份选择器 + ### v1.0.1 (2026-01-11) - 🐛 修复智能复核页面空数据显示错误 diff --git a/server/config.yaml b/server/config.yaml index 31a14cc..83a12ca 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -1,7 +1,7 @@ # BillAI 服务器配置文件 # 应用版本 -version: "1.0.0" +version: "1.0.2" # 服务配置 server: diff --git a/server/handler/manual_bills.go b/server/handler/manual_bills.go index 53a5ac2..4773e24 100644 --- a/server/handler/manual_bills.go +++ b/server/handler/manual_bills.go @@ -90,7 +90,7 @@ func CreateManualBills(c *gin.Context) { BillType: "manual", TransactionID: "", // 手动账单不设置交易ID MerchantOrderNo: "", - Time: t, + Time: model.LocalTime(t), Category: input.Category, Merchant: input.Merchant, Description: input.Description, diff --git a/server/model/bill.go b/server/model/bill.go index 3502c3b..46c7999 100644 --- a/server/model/bill.go +++ b/server/model/bill.go @@ -3,9 +3,70 @@ package model import ( "time" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/bsontype" "go.mongodb.org/mongo-driver/bson/primitive" ) +// LocalTime 自定义时间类型,JSON 序列化时输出本地时间格式 +type LocalTime time.Time + +// MarshalJSON 实现 json.Marshaler 接口,输出本地时间格式 +func (t LocalTime) MarshalJSON() ([]byte, error) { + tt := time.Time(t) + if tt.IsZero() { + return []byte(`""`), nil + } + // 输出格式: "2006-01-02 15:04:05" + return []byte(`"` + tt.Local().Format("2006-01-02 15:04:05") + `"`), nil +} + +// UnmarshalJSON 实现 json.Unmarshaler 接口 +func (t *LocalTime) UnmarshalJSON(data []byte) error { + s := string(data) + if s == `""` || s == "null" { + *t = LocalTime(time.Time{}) + return nil + } + // 去掉引号 + s = s[1 : len(s)-1] + + // 尝试多种格式解析 + formats := []string{ + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05Z", + "2006-01-02", + } + for _, format := range formats { + if parsed, err := time.ParseInLocation(format, s, time.Local); err == nil { + *t = LocalTime(parsed) + return nil + } + } + return nil +} + +// MarshalBSONValue 实现 bson.ValueMarshaler 接口 +func (t LocalTime) MarshalBSONValue() (bsontype.Type, []byte, error) { + return bson.MarshalValue(time.Time(t)) +} + +// UnmarshalBSONValue 实现 bson.ValueUnmarshaler 接口 +func (t *LocalTime) UnmarshalBSONValue(btype bsontype.Type, data []byte) error { + var tt time.Time + if err := bson.UnmarshalValue(btype, data, &tt); err != nil { + return err + } + *t = LocalTime(tt) + return nil +} + +// Time 返回标准 time.Time +func (t LocalTime) Time() time.Time { + return time.Time(t) +} + // RawBill 原始账单记录(存储上传的原始数据) type RawBill struct { ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` @@ -23,7 +84,7 @@ type CleanedBill struct { BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat 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"` // 交易时间 + Time LocalTime `bson:"time" json:"time"` // 交易时间(本地时间格式) Category string `bson:"category" json:"category"` // 交易分类 Merchant string `bson:"merchant" json:"merchant"` // 交易对方 Description string `bson:"description" json:"description"` // 商品说明 diff --git a/server/repository/mongo/repository.go b/server/repository/mongo/repository.go index a5a4983..26b7a8a 100644 --- a/server/repository/mongo/repository.go +++ b/server/repository/mongo/repository.go @@ -157,7 +157,7 @@ func (r *Repository) CheckCleanedDuplicate(bill *model.CleanedBill) (bool, error } else { // 回退到 时间+金额+商户 组合判断 filter = bson.M{ - "time": bill.Time, + "time": bill.Time.Time(), // 转换为 time.Time 用于 MongoDB 查询 "amount": bill.Amount, "merchant": bill.Merchant, } diff --git a/server/service/bill.go b/server/service/bill.go index 100fb45..2c61b5b 100644 --- a/server/service/bill.go +++ b/server/service/bill.go @@ -34,7 +34,7 @@ func checkDuplicate(ctx context.Context, bill *model.CleanedBill) bool { } else { // 回退到 时间+金额+商户 组合判断 filter = bson.M{ - "time": bill.Time, + "time": bill.Time.Time(), // 转换为 time.Time 用于 MongoDB 查询 "amount": bill.Amount, "merchant": bill.Merchant, } @@ -489,11 +489,11 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string } // parseTime 解析时间字符串 -// 使用本地时区解析,避免 UTC 时区问题 -func parseTime(s string) time.Time { +// 使用本地时区解析,返回 model.LocalTime 类型 +func parseTime(s string) model.LocalTime { s = strings.TrimSpace(s) if s == "" { - return time.Time{} + return model.LocalTime(time.Time{}) } // 尝试多种时间格式(使用本地时区) @@ -508,11 +508,11 @@ func parseTime(s string) time.Time { for _, format := range formats { if t, err := time.ParseInLocation(format, s, time.Local); err == nil { - return t + return model.LocalTime(t) } } - return time.Time{} + return model.LocalTime(time.Time{}) } // parseAmount 解析金额字符串 diff --git a/web/package.json b/web/package.json index 3a8ec00..de83860 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "1.0.1", + "version": "1.0.2", "type": "module", "scripts": { "dev": "vite dev", diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index f368181..77c76c1 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -16,6 +16,31 @@ export function formatLocalDate(date: Date): string { return `${y}-${m}-${d}`; } +/** + * 将 ISO 时间字符串(UTC)转换为本地时区的格式化字符串 + * 用于显示账单交易时间 + * @param isoString - ISO 格式的时间字符串,如 "2026-01-10T05:12:13Z" + * @returns 本地时间格式,如 "2026-01-10 13:12:13" + */ +export function formatDateTime(isoString: string): string { + if (!isoString) return ''; + try { + const date = new Date(isoString); + if (isNaN(date.getTime())) return isoString; + + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + + return `${y}-${m}-${d} ${h}:${min}:${s}`; + } catch { + return isoString; + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type WithoutChild = T extends { child?: any } ? Omit : T; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/web/src/routes/bills/+page.svelte b/web/src/routes/bills/+page.svelte index 87b38e9..5da3ea2 100644 --- a/web/src/routes/bills/+page.svelte +++ b/web/src/routes/bills/+page.svelte @@ -12,7 +12,7 @@ import { Separator } from '$lib/components/ui/separator'; import { DateRangePicker } from '$lib/components/ui/date-range-picker'; import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte'; - import { formatLocalDate } from '$lib/utils'; + import { formatLocalDate, formatDateTime } from '$lib/utils'; import Loader2 from '@lucide/svelte/icons/loader-2'; import AlertCircle from '@lucide/svelte/icons/alert-circle'; import Search from '@lucide/svelte/icons/search'; @@ -385,7 +385,7 @@ {#each displayRecords as record} - {record.time} + {formatDateTime(record.time)}