inti
This commit is contained in:
@@ -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
|
||||
皮肤:暗星
|
||||
绝密KD:0.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package hfb
|
||||
|
||||
import "testing"
|
||||
|
||||
const sampleText = `上号方式:QQ扫码
|
||||
哈夫币纯币:156
|
||||
段位:铂金
|
||||
等级:60
|
||||
保险格数:9
|
||||
每日消耗:10
|
||||
体力:7
|
||||
负重:7
|
||||
AWM子弹数量:80
|
||||
6头数量:13
|
||||
6甲数量:12
|
||||
皮肤:暗星
|
||||
绝密KD:0.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")
|
||||
}
|
||||
}
|
||||
@@ -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, "", " ")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user