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
+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))
}