This commit is contained in:
yml2213
2026-07-23 13:03:47 +08:00
commit 2a67d074a4
12 changed files with 999 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
dist/
*.tmp
*.log
.DS_Store
+88
View File
@@ -0,0 +1,88 @@
# updata_hfb
一个 Go 桌面 GUI 上传工具。用户打开程序后填写服务器、上传人、上传密钥,粘贴账号数据,点击“预览 JSON”或“上传”即可,不需要使用命令行参数。
## 本地运行
在项目根目录直接运行:
```bash
go run .
```
也可以运行 cmd 入口:
```bash
go run ./cmd/updata_hfb
```
启动后会打开窗口:
- `服务器`:默认 `http://127.0.0.1:8080`
- `上传人`:对应服务端的 `uploaderName`
- `上传密钥`:服务端外部上传密钥,未配置可留空
- `账号数据`:粘贴 `字段:值` 格式的数据
- `预览 JSON`:只转换,不上传
- `上传`:提交到服务端接口
## 文本格式
支持中文冒号 `` 和英文冒号 `:`
```text
上号方式:QQ扫码
哈夫币纯币:156
段位:铂金
等级:60
保险格数:9
每日消耗:10
体力:7
负重:7
AWM子弹数量:80
6头数量:13
6甲数量:12
皮肤:暗星
绝密KD0.2
押金:350
号主在线时间:11:00 至 00:00
封禁记录:无
常用地区:海南
回收比例:1:47.0
回收租金:332
联系电话:15203616450
```
## 环境变量
程序会读取这些环境变量作为窗口默认值:
- `HFB_SERVER`
- `HFB_UPLOADER_NAME`
- `HFB_UPLOAD_SECRET`
## 对接接口
- `POST /api/open/listing-uploads`
- 请求体是 JSON
- 如果填写上传密钥,会自动发送:
- `X-HFB-Timestamp`
- `X-HFB-Signature`
签名方式与服务端一致:`HMAC-SHA256(timestamp + "." + body)`
## 测试
```bash
go test ./...
go vet ./...
```
## 构建
当前 Mac
```bash
go build -o dist/updata_hfb ./cmd/updata_hfb
```
如果要给 Windows 打包,建议在 Windows 环境里构建,或者准备完整的 Fyne 交叉编译环境。
+7
View File
@@ -0,0 +1,7 @@
package main
import "updata_hfb/internal/app"
func main() {
app.Run()
}
+43
View File
@@ -0,0 +1,43 @@
module updata_hfb
go 1.22.0
require fyne.io/fyne/v2 v2.8.0
require (
fyne.io/systray v1.12.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+87
View File
@@ -0,0 +1,87 @@
fyne.io/fyne/v2 v2.8.0 h1:KNUdIk1eKsXSPy/wU6MdiR1hppAPvyzbjPbtJ8h6EUQ=
fyne.io/fyne/v2 v2.8.0/go.mod h1:tLJK7CVtUBOnMiSDR+J88t/quiGuEhwGs09tIVM1RXg=
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
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/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+236
View File
@@ -0,0 +1,236 @@
package app
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
"fyne.io/fyne/v2"
fyneapp "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"updata_hfb/internal/hfb"
)
const sampleText = `上号方式:QQ扫码
哈夫币纯币:156
段位:铂金
等级:60
保险格数:9
每日消耗:10
体力:7
负重:7
AWM子弹数量:80
6头数量:13
6甲数量:12
皮肤:暗星
绝密KD0.2
押金:350
号主在线时间:11:00 至 00:00
封禁记录:无
常用地区:海南
回收比例:1:47.0
回收租金:332
联系电话:15203616450`
func Run() {
a := fyneapp.NewWithID("com.hfb.updata")
w := a.NewWindow("哈夫币外部上传")
w.Resize(fyne.NewSize(920, 760))
serverEntry := widget.NewEntry()
serverEntry.SetText(envDefault("HFB_SERVER", "http://127.0.0.1:8080"))
serverEntry.SetPlaceHolder("例如:http://127.0.0.1:8080")
uploaderEntry := widget.NewEntry()
uploaderEntry.SetText(strings.TrimSpace(os.Getenv("HFB_UPLOADER_NAME")))
uploaderEntry.SetPlaceHolder("后台上传管理员名称")
secretEntry := widget.NewEntry()
secretEntry.Password = true
secretEntry.SetText(strings.TrimSpace(os.Getenv("HFB_UPLOAD_SECRET")))
secretEntry.SetPlaceHolder("未配置密钥可留空")
timeoutEntry := widget.NewEntry()
timeoutEntry.SetText("15s")
timeoutEntry.SetPlaceHolder("例如:15s")
dataEntry := widget.NewMultiLineEntry()
dataEntry.Wrapping = fyne.TextWrapWord
dataEntry.SetMinRowsVisible(18)
dataEntry.SetPlaceHolder(sampleText)
resultEntry := widget.NewMultiLineEntry()
resultEntry.Wrapping = fyne.TextWrapWord
resultEntry.SetMinRowsVisible(8)
resultEntry.Disable()
statusLabel := widget.NewLabel("准备就绪")
setResult := func(text string) {
resultEntry.Enable()
resultEntry.SetText(text)
resultEntry.Disable()
}
setBusy := func(uploadBtn *widget.Button, previewBtn *widget.Button, busy bool) {
if busy {
uploadBtn.Disable()
previewBtn.Disable()
statusLabel.SetText("正在上传,请稍等...")
return
}
uploadBtn.Enable()
previewBtn.Enable()
}
var uploadBtn *widget.Button
var previewBtn *widget.Button
loadBtn := widget.NewButton("打开文件", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(fmt.Errorf("打开文件失败:%w", err), w)
return
}
if reader == nil {
return
}
defer reader.Close()
content, err := io.ReadAll(reader)
if err != nil {
dialog.ShowError(fmt.Errorf("读取文件失败:%w", err), w)
return
}
dataEntry.SetText(string(content))
statusLabel.SetText("已载入文件")
}, w)
})
sampleBtn := widget.NewButton("填入示例", func() {
dataEntry.SetText(sampleText)
statusLabel.SetText("已填入示例数据")
})
clearBtn := widget.NewButton("清空", func() {
dataEntry.SetText("")
setResult("")
statusLabel.SetText("已清空")
})
previewBtn = widget.NewButton("预览 JSON", func() {
req, err := buildRequest(uploaderEntry.Text, dataEntry.Text)
if err != nil {
statusLabel.SetText("解析失败")
dialog.ShowError(err, w)
return
}
out, err := hfb.MarshalPretty(req)
if err != nil {
statusLabel.SetText("生成 JSON 失败")
dialog.ShowError(err, w)
return
}
setResult(string(out))
statusLabel.SetText("预览已生成")
})
uploadBtn = widget.NewButton("上传", func() {
timeout, err := time.ParseDuration(strings.TrimSpace(timeoutEntry.Text))
if err != nil || timeout <= 0 {
dialog.ShowError(fmt.Errorf("请求超时时间不正确:%s", timeoutEntry.Text), w)
return
}
req, err := buildRequest(uploaderEntry.Text, dataEntry.Text)
if err != nil {
statusLabel.SetText("解析失败")
dialog.ShowError(err, w)
return
}
server := strings.TrimSpace(serverEntry.Text)
secret := strings.TrimSpace(secretEntry.Text)
setBusy(uploadBtn, previewBtn, true)
setResult("")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
resp, err := hfb.Client{
Server: server,
Secret: secret,
Timeout: timeout,
}.Upload(ctx, req)
fyne.Do(func() {
setBusy(uploadBtn, previewBtn, false)
if err != nil {
statusLabel.SetText("上传失败")
setResult(err.Error())
dialog.ShowError(err, w)
return
}
statusLabel.SetText("上传成功")
setResult(resp)
dialog.ShowInformation("上传成功", "数据已经提交到服务器。", w)
})
}()
})
uploadBtn.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem("服务器", serverEntry),
widget.NewFormItem("上传人", uploaderEntry),
widget.NewFormItem("上传密钥", secretEntry),
widget.NewFormItem("超时时间", timeoutEntry),
)
toolbar := container.NewHBox(loadBtn, sampleBtn, clearBtn, previewBtn, uploadBtn)
content := container.NewBorder(
nil,
container.NewVBox(statusLabel),
nil,
nil,
container.NewVScroll(container.NewVBox(
widget.NewLabel("连接配置"),
form,
toolbar,
widget.NewLabel("账号数据"),
dataEntry,
widget.NewLabel("结果"),
resultEntry,
)),
)
w.SetContent(content)
w.ShowAndRun()
}
func buildRequest(uploaderName string, rawText string) (hfb.ExternalUploadRequest, error) {
if strings.TrimSpace(rawText) == "" {
return hfb.ExternalUploadRequest{}, fmt.Errorf("账号数据不能为空")
}
parsed, err := hfb.ParseUploadText(rawText)
if err != nil {
return hfb.ExternalUploadRequest{}, err
}
name := strings.TrimSpace(uploaderName)
if name == "" {
name = parsed.UploaderName
}
if name == "" {
return hfb.ExternalUploadRequest{}, fmt.Errorf("缺少上传人名称:请在“上传人”里填写,或在文本中增加“上传人:姓名”")
}
return hfb.NewExternalUploadRequest(name, parsed.Account, time.Now()), nil
}
func envDefault(key string, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
+214
View File
@@ -0,0 +1,214 @@
package hfb
import (
"fmt"
"regexp"
"strconv"
"strings"
)
var numberPattern = regexp.MustCompile(`[-+]?\d+(?:\.\d+)?`)
func ParseUploadText(text string) (ParsedUpload, error) {
values := make(map[string]string)
for lineNo, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
key, value, ok := splitKeyValue(line)
if !ok {
return ParsedUpload{}, fmt.Errorf("第 %d 行格式不正确,应为“字段:值”", lineNo+1)
}
key = normalizeKey(key)
if key == "" {
continue
}
values[key] = strings.TrimSpace(value)
}
account := ExternalAccountData{}
var err error
account.LoginMethod = firstValue(values, "上号方式", "登录方式")
account.Rank = firstValue(values, "段位")
account.Level, err = parseRequiredInt(values, "等级")
if err != nil {
return ParsedUpload{}, err
}
account.SafeSlots, err = parseRequiredInt(values, "保险格数", "保险箱格数")
if err != nil {
return ParsedUpload{}, err
}
account.DailyLossM, err = parseRequiredFloat(values, "每日消耗")
if err != nil {
return ParsedUpload{}, err
}
account.SecretKD, err = parseRequiredFloat(values, "绝密KD", "绝密kd")
if err != nil {
return ParsedUpload{}, err
}
account.Deposit, err = parseRequiredFloat(values, "押金")
if err != nil {
return ParsedUpload{}, err
}
account.BanRecord = optionalDefault(firstValue(values, "封禁记录"), "无")
account.CommonRegion = firstValue(values, "常用地区", "常用区域")
account.ContactPhone = firstValue(values, "联系电话", "手机号", "电话")
account.OwnerOnlineTime = firstValue(values, "号主在线时间", "在线时间")
account.Currency.HafuCoin, err = parseRequiredFloat(values, "哈夫币纯币", "哈夫币")
if err != nil {
return ParsedUpload{}, err
}
account.Currency.RecycleRatio, err = parseRequiredRatio(values, "回收比例")
if err != nil {
return ParsedUpload{}, err
}
account.Currency.RecycleRent, err = parseRequiredFloat(values, "回收租金")
if err != nil {
return ParsedUpload{}, err
}
account.DailyConsumption.Stamina, err = parseRequiredInt(values, "体力")
if err != nil {
return ParsedUpload{}, err
}
account.DailyConsumption.Weight, err = parseRequiredInt(values, "负重")
if err != nil {
return ParsedUpload{}, err
}
account.Inventory.AWMBullets, err = parseOptionalInt(values, "AWM子弹数量", "AWM子弹")
if err != nil {
return ParsedUpload{}, err
}
account.Inventory.Level6Helmets, err = parseOptionalInt(values, "6头数量", "六头数量", "6头")
if err != nil {
return ParsedUpload{}, err
}
account.Inventory.Level6Armor, err = parseOptionalInt(values, "6甲数量", "六甲数量", "6甲")
if err != nil {
return ParsedUpload{}, err
}
account.Inventory.Skins = parseSkins(firstValue(values, "皮肤"))
if strings.TrimSpace(account.LoginMethod) == "" {
return ParsedUpload{}, fmt.Errorf("缺少必填字段:上号方式")
}
if strings.TrimSpace(account.Rank) == "" {
return ParsedUpload{}, fmt.Errorf("缺少必填字段:段位")
}
return ParsedUpload{
UploaderName: firstValue(values, "上传人", "上传者", "上传账号", "上传管理员"),
Account: account,
}, nil
}
func splitKeyValue(line string) (string, string, bool) {
if idx := strings.Index(line, ""); idx >= 0 {
return line[:idx], line[idx+len(""):], true
}
if idx := strings.Index(line, ":"); idx >= 0 {
return line[:idx], line[idx+1:], true
}
return "", "", false
}
func normalizeKey(key string) string {
return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(key), " ", ""))
}
func firstValue(values map[string]string, labels ...string) string {
for _, label := range labels {
if value, ok := values[normalizeKey(label)]; ok {
return strings.TrimSpace(value)
}
}
return ""
}
func optionalDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
func parseRequiredInt(values map[string]string, labels ...string) (int, error) {
value := firstValue(values, labels...)
if value == "" {
return 0, fmt.Errorf("缺少必填字段:%s", labels[0])
}
return parseIntLabel(labels[0], value)
}
func parseOptionalInt(values map[string]string, labels ...string) (int, error) {
value := firstValue(values, labels...)
if value == "" {
return 0, nil
}
return parseIntLabel(labels[0], value)
}
func parseIntLabel(label, value string) (int, error) {
number, err := parseFloatText(value, false)
if err != nil {
return 0, fmt.Errorf("%s 数字格式不正确:%s", label, value)
}
return int(number), nil
}
func parseRequiredFloat(values map[string]string, labels ...string) (float64, error) {
value := firstValue(values, labels...)
if value == "" {
return 0, fmt.Errorf("缺少必填字段:%s", labels[0])
}
number, err := parseFloatText(value, false)
if err != nil {
return 0, fmt.Errorf("%s 数字格式不正确:%s", labels[0], value)
}
return number, nil
}
func parseRequiredRatio(values map[string]string, labels ...string) (float64, error) {
value := firstValue(values, labels...)
if value == "" {
return 0, fmt.Errorf("缺少必填字段:%s", labels[0])
}
number, err := parseFloatText(value, true)
if err != nil {
return 0, fmt.Errorf("%s 数字格式不正确:%s", labels[0], value)
}
return number, nil
}
func parseFloatText(value string, preferLast bool) (float64, error) {
value = strings.TrimSpace(strings.ReplaceAll(value, ",", ""))
matches := numberPattern.FindAllString(value, -1)
if len(matches) == 0 {
return 0, strconv.ErrSyntax
}
pick := matches[0]
if preferLast {
pick = matches[len(matches)-1]
}
return strconv.ParseFloat(pick, 64)
}
func parseSkins(value string) []string {
value = strings.TrimSpace(value)
if value == "" || value == "无" || strings.EqualFold(value, "none") {
return nil
}
parts := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == '' || r == '、' || r == ';' || r == ''
})
skins := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" && part != "无" {
skins = append(skins, part)
}
}
return skins
}
+68
View File
@@ -0,0 +1,68 @@
package hfb
import "testing"
const sampleText = `上号方式:QQ扫码
哈夫币纯币:156
段位:铂金
等级:60
保险格数:9
每日消耗:10
体力:7
负重:7
AWM子弹数量:80
6头数量:13
6甲数量:12
皮肤:暗星
绝密KD0.2
押金:350
号主在线时间:11:00 至 00:00
封禁记录:无
常用地区:海南
回收比例:1:47.0
回收租金:332
联系电话:15203616450`
func TestParseUploadTextSample(t *testing.T) {
parsed, err := ParseUploadText(sampleText)
if err != nil {
t.Fatalf("ParseUploadText() error = %v", err)
}
account := parsed.Account
if account.LoginMethod != "QQ扫码" {
t.Fatalf("LoginMethod = %q", account.LoginMethod)
}
if account.Currency.HafuCoin != 156 {
t.Fatalf("HafuCoin = %v", account.Currency.HafuCoin)
}
if account.Currency.RecycleRatio != 47 {
t.Fatalf("RecycleRatio = %v", account.Currency.RecycleRatio)
}
if account.ContactPhone != "15203616450" {
t.Fatalf("ContactPhone = %q", account.ContactPhone)
}
if got := len(account.Inventory.Skins); got != 1 || account.Inventory.Skins[0] != "暗星" {
t.Fatalf("Skins = %#v", account.Inventory.Skins)
}
}
func TestParseUploadTextASCIIDelimiterKeepsRatioValue(t *testing.T) {
text := sampleText + "\n上传人:张三"
parsed, err := ParseUploadText(text)
if err != nil {
t.Fatalf("ParseUploadText() error = %v", err)
}
if parsed.UploaderName != "张三" {
t.Fatalf("UploaderName = %q", parsed.UploaderName)
}
if parsed.Account.Currency.RecycleRatio != 47 {
t.Fatalf("RecycleRatio = %v", parsed.Account.Currency.RecycleRatio)
}
}
func TestParseUploadTextMissingRequiredField(t *testing.T) {
_, err := ParseUploadText("上号方式:QQ扫码")
if err == nil {
t.Fatal("ParseUploadText() expected error")
}
}
+64
View File
@@ -0,0 +1,64 @@
package hfb
import (
"encoding/json"
"time"
)
type ParsedUpload struct {
UploaderName string
Account ExternalAccountData
}
type ExternalUploadRequest struct {
UploadTime int64 `json:"uploadTime"`
UploaderName string `json:"uploaderName"`
Data ExternalAccountData `json:"data"`
}
type ExternalAccountData struct {
LoginMethod string `json:"loginMethod"`
Rank string `json:"rank"`
Level int `json:"level"`
SafeSlots int `json:"safeSlots"`
SecretKD float64 `json:"secretKD"`
DailyLossM float64 `json:"dailyLossM"`
Deposit float64 `json:"deposit"`
BanRecord string `json:"banRecord"`
CommonRegion string `json:"commonRegion"`
ContactPhone string `json:"contactPhone"`
OwnerOnlineTime string `json:"ownerOnlineTime"`
Currency ExternalUploadCurrency `json:"currency"`
DailyConsumption ExternalDailyConsumption `json:"dailyConsumption"`
Inventory ExternalUploadInventory `json:"inventory"`
}
type ExternalUploadCurrency struct {
HafuCoin float64 `json:"hafuCoin"`
RecycleRatio float64 `json:"recycleRatio"`
RecycleRent float64 `json:"recycleRent"`
}
type ExternalDailyConsumption struct {
Stamina int `json:"stamina"`
Weight int `json:"weight"`
}
type ExternalUploadInventory struct {
AWMBullets int `json:"awmBullets"`
Level6Helmets int `json:"level6Helmets"`
Level6Armor int `json:"level6Armor"`
Skins []string `json:"skins"`
}
func NewExternalUploadRequest(uploaderName string, account ExternalAccountData, now time.Time) ExternalUploadRequest {
return ExternalUploadRequest{
UploadTime: now.Unix(),
UploaderName: uploaderName,
Data: account,
}
}
func MarshalPretty(req ExternalUploadRequest) ([]byte, error) {
return json.MarshalIndent(req, "", " ")
}
+106
View File
@@ -0,0 +1,106 @@
package hfb
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const uploadPath = "/api/open/listing-uploads"
type Client struct {
Server string
Secret string
Timeout time.Duration
HTTPClient *http.Client
}
func (c Client) Upload(ctx context.Context, req ExternalUploadRequest) (string, error) {
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("生成上传 JSON 失败:%w", err)
}
endpoint, err := normalizeEndpoint(c.Server)
if err != nil {
return "", err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("创建上传请求失败:%w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("User-Agent", "updata_hfb/1.0")
if strings.TrimSpace(c.Secret) != "" {
timestamp := strconvFormatUnix(time.Now())
httpReq.Header.Set("X-HFB-Timestamp", timestamp)
httpReq.Header.Set("X-HFB-Signature", signBody(c.Secret, timestamp, body))
}
httpClient := c.HTTPClient
if httpClient == nil {
timeout := c.Timeout
if timeout <= 0 {
timeout = 15 * time.Second
}
httpClient = &http.Client{Timeout: timeout}
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("请求服务器失败:%w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("读取服务器响应失败:%w", err)
}
text := strings.TrimSpace(string(respBody))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if text == "" {
text = resp.Status
}
return "", fmt.Errorf("服务器返回失败状态 %s%s", resp.Status, text)
}
if text == "" {
return resp.Status, nil
}
return text, nil
}
func normalizeEndpoint(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("服务器地址不能为空")
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("服务器地址不正确:%s", raw)
}
if strings.Contains(parsed.Path, uploadPath) {
return parsed.String(), nil
}
basePath := strings.TrimRight(parsed.Path, "/")
parsed.Path = basePath + uploadPath
return parsed.String(), nil
}
func signBody(secret, timestamp string, body []byte) string {
mac := hmac.New(sha256.New, []byte(strings.TrimSpace(secret)))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
func strconvFormatUnix(t time.Time) string {
return fmt.Sprintf("%d", t.Unix())
}
+75
View File
@@ -0,0 +1,75 @@
package hfb
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestUploadSendsSignedRequest(t *testing.T) {
const secret = "test-secret"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != uploadPath {
t.Fatalf("path = %q", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
timestamp := r.Header.Get("X-HFB-Timestamp")
signature := r.Header.Get("X-HFB-Signature")
if timestamp == "" || signature == "" {
t.Fatal("missing signature headers")
}
if signature != expectedSignature(secret, timestamp, body) {
t.Fatalf("signature = %q", signature)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
client := Client{Server: server.URL, Secret: secret, Timeout: time.Second}
req := NewExternalUploadRequest("张三", mustSampleAccount(t), time.Unix(100, 0))
resp, err := client.Upload(context.Background(), req)
if err != nil {
t.Fatalf("Upload() error = %v", err)
}
if resp != `{"ok":true}` {
t.Fatalf("resp = %q", resp)
}
}
func TestNormalizeEndpoint(t *testing.T) {
got, err := normalizeEndpoint("http://127.0.0.1:8080")
if err != nil {
t.Fatalf("normalizeEndpoint() error = %v", err)
}
want := "http://127.0.0.1:8080" + uploadPath
if got != want {
t.Fatalf("endpoint = %q, want %q", got, want)
}
}
func mustSampleAccount(t *testing.T) ExternalAccountData {
t.Helper()
parsed, err := ParseUploadText(sampleText)
if err != nil {
t.Fatalf("ParseUploadText() error = %v", err)
}
return parsed.Account
}
func expectedSignature(secret, timestamp string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
+7
View File
@@ -0,0 +1,7 @@
package main
import "updata_hfb/internal/app"
func main() {
app.Run()
}