From bac7a7b7088ac0b338f2ab896d647c8b351927bf Mon Sep 17 00:00:00 2001 From: openclaw Date: Sun, 15 Feb 2026 21:52:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TG: /chart 本月分类饼图, /week 近7天消费柱状图 - QQ: 统计/报表 本月文本统计 - 新增 go-chart 依赖生成 PNG 图表 - 新增 GetCategoryStats/GetDailyStats 查询方法 --- go.mod | 13 ++-- go.sum | 36 +++++++++++ internal/bot/telegram.go | 89 ++++++++++++++++++++++++- internal/chart/chart.go | 126 ++++++++++++++++++++++++++++++++++++ internal/qq/qq.go | 22 +++++++ internal/service/finance.go | 38 +++++++++++ 6 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 internal/chart/chart.go diff --git a/go.mod b/go.mod index b3646cd..4958cb8 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-resty/resty/v2 v2.6.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -38,12 +39,14 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/wcharczuk/go-chart/v2 v2.1.2 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.16.0 // indirect - golang.org/x/net v0.19.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/image v0.18.0 // indirect + golang.org/x/net v0.25.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.16.0 // indirect google.golang.org/protobuf v1.30.0 // indirect ) diff --git a/go.sum b/go.sum index 8967b8f..38b9066 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGi github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -52,6 +54,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= @@ -125,6 +128,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E= +github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ= github.com/yanyiwu/gojieba v1.3.0 h1:6VeaPOR+MawnImdeSvWNr7rP4tvUfnGlEKaoBnR33Ds= github.com/yanyiwu/gojieba v1.3.0/go.mod h1:54wkP7sMJ6bklf7yPl6F+JG71dzVUU1WigZbR47nGdY= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -136,11 +141,20 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -152,8 +166,12 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -162,6 +180,10 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -181,26 +203,40 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/bot/telegram.go b/internal/bot/telegram.go index 7ae4a98..29205aa 100644 --- a/internal/bot/telegram.go +++ b/internal/bot/telegram.go @@ -7,6 +7,7 @@ import ( "strings" "time" + xchart "xiaji-go/internal/chart" "xiaji-go/internal/service" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -61,10 +62,10 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) { switch { case text == "/start": - reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/help - 帮助" + reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助" case text == "/help": - reply = "📖 使用说明:\n\n直接发送带金额的文本即可自动记账。\n系统会自动识别金额和消费分类。\n\n支持格式:\n• 午饭 25元\n• ¥30 打车\n• 买水果15块\n\n命令:\n/list - 最近10条记录\n/today - 今日汇总\n/start - 欢迎信息" + reply = "📖 使用说明:\n\n直接发送带金额的文本即可自动记账。\n系统会自动识别金额和消费分类。\n\n支持格式:\n• 午饭 25元\n• ¥30 打车\n• 买水果15块\n\n命令:\n/list - 最近10条记录\n/today - 今日汇总\n/chart - 本月消费图表\n/week - 近7天每日趋势\n/start - 欢迎信息" case text == "/today": today := time.Now().Format("2006-01-02") @@ -85,6 +86,14 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) { reply = sb.String() } + case text == "/chart": + b.sendMonthlyChart(chatID) + return + + case text == "/week": + b.sendWeeklyChart(chatID) + return + case text == "/list": items, err := b.finance.GetTransactions(DefaultUserID, 10) if err != nil { @@ -122,3 +131,79 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) { log.Printf("发送消息失败 chat=%d: %v", chatID, err) } } + +// sendMonthlyChart 发送本月分类饼图 +func (b *TGBot) sendMonthlyChart(chatID int64) { + now := time.Now() + dateFrom := now.Format("2006-01") + "-01" + dateTo := now.Format("2006-01-02") + title := fmt.Sprintf("%d年%d月消费分类", now.Year(), now.Month()) + + stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo) + if err != nil || len(stats) == 0 { + m := tgbotapi.NewMessage(chatID, "📭 本月暂无消费数据") + b.api.Send(m) + return + } + + imgData, err := xchart.GeneratePieChart(stats, title) + if err != nil { + log.Printf("生成饼图失败: %v", err) + m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败") + b.api.Send(m) + return + } + + // 计算总计文字 + var total int64 + var totalCount int + for _, s := range stats { + total += s.Total + totalCount += s.Count + } + caption := fmt.Sprintf("📊 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0) + + photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData}) + photo.Caption = caption + if _, err := b.api.Send(photo); err != nil { + log.Printf("发送图表失败 chat=%d: %v", chatID, err) + } +} + +// sendWeeklyChart 发送近7天每日消费柱状图 +func (b *TGBot) sendWeeklyChart(chatID int64) { + now := time.Now() + dateFrom := now.AddDate(0, 0, -6).Format("2006-01-02") + dateTo := now.Format("2006-01-02") + title := fmt.Sprintf("近7天消费趋势 (%s ~ %s)", dateFrom[5:], dateTo[5:]) + + stats, err := b.finance.GetDailyStats(DefaultUserID, dateFrom, dateTo) + if err != nil || len(stats) == 0 { + m := tgbotapi.NewMessage(chatID, "📭 近7天暂无消费数据") + b.api.Send(m) + return + } + + imgData, err := xchart.GenerateBarChart(stats, title) + if err != nil { + log.Printf("生成柱状图失败: %v", err) + m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败") + b.api.Send(m) + return + } + + // 总计 + var total int64 + var totalCount int + for _, s := range stats { + total += s.Total + totalCount += s.Count + } + caption := fmt.Sprintf("📈 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0) + + photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData}) + photo.Caption = caption + if _, err := b.api.Send(photo); err != nil { + log.Printf("发送图表失败 chat=%d: %v", chatID, err) + } +} diff --git a/internal/chart/chart.go b/internal/chart/chart.go new file mode 100644 index 0000000..c06d8d7 --- /dev/null +++ b/internal/chart/chart.go @@ -0,0 +1,126 @@ +package chart + +import ( + "bytes" + "fmt" + "math" + + "xiaji-go/internal/service" + + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +// 分类对应的颜色 +var categoryColors = []drawing.Color{ + {R: 255, G: 99, B: 132, A: 255}, // 红 + {R: 54, G: 162, B: 235, A: 255}, // 蓝 + {R: 255, G: 206, B: 86, A: 255}, // 黄 + {R: 75, G: 192, B: 192, A: 255}, // 青 + {R: 153, G: 102, B: 255, A: 255}, // 紫 + {R: 255, G: 159, B: 64, A: 255}, // 橙 + {R: 46, G: 204, B: 113, A: 255}, // 绿 + {R: 231, G: 76, B: 60, A: 255}, // 深红 + {R: 52, G: 73, B: 94, A: 255}, // 深蓝灰 + {R: 241, G: 196, B: 15, A: 255}, // 金 +} + +// GeneratePieChart 生成分类占比饼图 +func GeneratePieChart(stats []service.CategoryStat, title string) ([]byte, error) { + if len(stats) == 0 { + return nil, fmt.Errorf("no data") + } + + var total float64 + for _, s := range stats { + total += float64(s.Total) + } + + var values []chart.Value + for i, s := range stats { + yuan := float64(s.Total) / 100.0 + pct := float64(s.Total) / total * 100 + label := fmt.Sprintf("%s %.0f元(%.0f%%)", s.Category, yuan, pct) + values = append(values, chart.Value{ + Value: yuan, + Label: label, + Style: chart.Style{ + FillColor: categoryColors[i%len(categoryColors)], + StrokeColor: drawing.ColorWhite, + StrokeWidth: 2, + }, + }) + } + + pie := chart.PieChart{ + Title: title, + Width: 600, + Height: 500, + TitleStyle: chart.Style{ + FontSize: 16, + }, + Values: values, + } + + buf := &bytes.Buffer{} + if err := pie.Render(chart.PNG, buf); err != nil { + return nil, fmt.Errorf("render pie chart: %w", err) + } + return buf.Bytes(), nil +} + +// GenerateBarChart 生成每日消费柱状图 +func GenerateBarChart(stats []service.DailyStat, title string) ([]byte, error) { + if len(stats) == 0 { + return nil, fmt.Errorf("no data") + } + + var values []chart.Value + var maxVal float64 + for _, s := range stats { + yuan := float64(s.Total) / 100.0 + if yuan > maxVal { + maxVal = yuan + } + // 日期只取 MM-DD + dateLabel := s.Date + if len(s.Date) > 5 { + dateLabel = s.Date[5:] + } + values = append(values, chart.Value{ + Value: yuan, + Label: dateLabel, + Style: chart.Style{ + FillColor: drawing.Color{R: 54, G: 162, B: 235, A: 255}, + StrokeColor: drawing.Color{R: 54, G: 162, B: 235, A: 255}, + StrokeWidth: 1, + }, + }) + } + + bar := chart.BarChart{ + Title: title, + Width: 600, + Height: 400, + TitleStyle: chart.Style{ + FontSize: 16, + }, + YAxis: chart.YAxis{ + Range: &chart.ContinuousRange{ + Min: 0, + Max: math.Ceil(maxVal*1.2/10) * 10, + }, + ValueFormatter: func(v interface{}) string { + return fmt.Sprintf("%.0f", v) + }, + }, + BarWidth: 40, + Bars: values, + } + + buf := &bytes.Buffer{} + if err := bar.Render(chart.PNG, buf); err != nil { + return nil, fmt.Errorf("render bar chart: %w", err) + } + return buf.Bytes(), nil +} diff --git a/internal/qq/qq.go b/internal/qq/qq.go index 4867550..2c2278f 100644 --- a/internal/qq/qq.go +++ b/internal/qq/qq.go @@ -102,6 +102,7 @@ func (b *QQBot) processAndReply(userID string, content string) string { "📋 命令列表:\n" + "• 记录/查看 — 最近10条\n" + "• 今日/今天 — 今日汇总\n" + + "• 统计/报表 — 本月分类统计\n" + "• 帮助 — 本帮助信息" case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"): @@ -136,6 +137,27 @@ func (b *QQBot) processAndReply(userID string, content string) string { } sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0)) return sb.String() + + case isCommand(text, "统计", "报表", "图表", "chart", "/chart"): + now := time.Now() + dateFrom := now.Format("2006-01") + "-01" + dateTo := now.Format("2006-01-02") + stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo) + if err != nil || len(stats) == 0 { + return fmt.Sprintf("📭 %d年%d月暂无消费数据", now.Year(), now.Month()) + } + var sb strings.Builder + var grandTotal int64 + var grandCount int + sb.WriteString(fmt.Sprintf("📊 %d年%d月消费统计:\n\n", now.Year(), now.Month())) + for _, s := range stats { + yuan := float64(s.Total) / 100.0 + sb.WriteString(fmt.Sprintf("• %s:%.2f元(%d笔)\n", s.Category, yuan, s.Count)) + grandTotal += s.Total + grandCount += s.Count + } + sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", grandCount, float64(grandTotal)/100.0)) + return sb.String() } amount, category, err := b.finance.AddTransaction(DefaultUserID, text) diff --git a/internal/service/finance.go b/internal/service/finance.go index 67356f3..5cf303e 100644 --- a/internal/service/finance.go +++ b/internal/service/finance.go @@ -114,3 +114,41 @@ func (s *FinanceService) GetTransactionsByDate(userID int64, date string) ([]mod Order("id desc").Find(&items).Error return items, err } + +// CategoryStat 分类统计结果 +type CategoryStat struct { + Category string + Total int64 + Count int +} + +// GetCategoryStats 获取用户指定日期范围的分类统计 +func (s *FinanceService) GetCategoryStats(userID int64, dateFrom, dateTo string) ([]CategoryStat, error) { + var stats []CategoryStat + err := s.db.Model(&models.Transaction{}). + Select("category, SUM(amount) as total, COUNT(*) as count"). + Where("user_id = ? AND date >= ? AND date <= ? AND is_deleted = ?", userID, dateFrom, dateTo, false). + Group("category"). + Order("total desc"). + Find(&stats).Error + return stats, err +} + +// DailyStat 每日统计结果 +type DailyStat struct { + Date string + Total int64 + Count int +} + +// GetDailyStats 获取用户指定日期范围的每日统计 +func (s *FinanceService) GetDailyStats(userID int64, dateFrom, dateTo string) ([]DailyStat, error) { + var stats []DailyStat + err := s.db.Model(&models.Transaction{}). + Select("date, SUM(amount) as total, COUNT(*) as count"). + Where("user_id = ? AND date >= ? AND date <= ? AND is_deleted = ?", userID, dateFrom, dateTo, false). + Group("date"). + Order("date asc"). + Find(&stats).Error + return stats, err +}