Files
2026-10-25 15:02:41 +08:00

448 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
"sync"
"time"
"hfb_price_tool/internal/pricing"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const (
appName = "hfb-price-tool"
appVersion = "1.0.1"
publishEndpoint = "/api/listing-publish-options"
)
type App struct {
ctx context.Context
mu sync.RWMutex
config pricing.Config
configPath string
loadError string
}
type Bootstrap struct {
Config pricing.Config `json:"config"`
ConfigPath string `json:"config_path"`
ServerURL string `json:"server_url"`
LoadError string `json:"load_error,omitempty"`
}
type SyncResult struct {
Config pricing.Config `json:"config"`
ServerURL string `json:"server_url"`
SyncedAt string `json:"synced_at"`
InsuranceCount int `json:"insurance_count"`
RatioItemCount int `json:"ratio_item_count"`
CoinCorrectionCount int `json:"coin_correction_count"`
QuantityItemCount int `json:"quantity_item_count"`
}
type ExportResult struct {
Path string `json:"path"`
Cancelled bool `json:"cancelled"`
}
type ImportResult struct {
Config pricing.Config `json:"config"`
Path string `json:"path"`
Cancelled bool `json:"cancelled"`
}
type remotePublishOptions struct {
RatioConfig struct {
InsuranceBaseRatios []pricing.InsuranceBaseRatio `json:"insurance_base_ratios"`
ConfigItems []pricing.RatioConfigItem `json:"config_items"`
CoinCorrections []pricing.CoinCorrection `json:"coin_corrections"`
} `json:"ratio_config"`
QuantityItems []pricing.QuantityItem `json:"quantity_items"`
}
func NewApp() *App {
return &App{
config: pricing.DefaultConfig(),
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
path, err := defaultConfigPath()
if err != nil {
a.mu.Lock()
a.loadError = "无法读取本地配置目录:" + err.Error()
a.mu.Unlock()
return
}
a.mu.Lock()
a.configPath = path
a.mu.Unlock()
config, loadErr := loadConfigFile(path)
a.mu.Lock()
a.config = config
a.loadError = loadErr
a.mu.Unlock()
}
// Bootstrap 返回启动所需的本地规则、配置路径和默认线上地址。
func (a *App) Bootstrap() Bootstrap {
a.mu.RLock()
defer a.mu.RUnlock()
return Bootstrap{
Config: a.config,
ConfigPath: a.configPath,
ServerURL: a.config.SyncURL,
LoadError: a.loadError,
}
}
// Calculate 按当前规则计算号主到手价。
func (a *App) Calculate(input pricing.Input) pricing.Result {
a.mu.RLock()
config := a.config
a.mu.RUnlock()
return config.Calculate(input)
}
// SaveConfig 校验并保存本地规则。
func (a *App) SaveConfig(config pricing.Config) (pricing.Config, error) {
normalized := pricing.Normalize(config)
if err := a.persist(normalized); err != nil {
return pricing.Config{}, err
}
return normalized, nil
}
// ResetConfig 恢复内置默认规则并落盘。
func (a *App) ResetConfig() (pricing.Config, error) {
config := pricing.DefaultConfig()
if err := a.persist(config); err != nil {
return pricing.Config{}, err
}
return config, nil
}
// SyncFromServer 从主站公开接口拉取发布规则,覆盖保险、缺配、大额修正和物品。
func (a *App) SyncFromServer(rawURL string) (SyncResult, error) {
baseURL, err := normalizeServerURL(rawURL)
if err != nil {
return SyncResult{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
remote, err := fetchRemotePublishOptions(ctx, baseURL)
if err != nil {
return SyncResult{}, err
}
if len(remote.RatioConfig.InsuranceBaseRatios) == 0 || len(remote.RatioConfig.ConfigItems) == 0 {
return SyncResult{}, errors.New("线上未返回可用的比例规则,请确认地址是否为哈夫币站点")
}
a.mu.RLock()
current := a.config
a.mu.RUnlock()
next := current
next.InsuranceBaseRatios = remote.RatioConfig.InsuranceBaseRatios
next.RatioConfigItems = remote.RatioConfig.ConfigItems
next.CoinCorrections = remote.RatioConfig.CoinCorrections
next.QuantityItems = mergeRemoteQuantityItems(current.QuantityItems, remote.QuantityItems)
next.SyncURL = baseURL
next = pricing.Normalize(next)
if err := a.persist(next); err != nil {
return SyncResult{}, err
}
return SyncResult{
Config: next,
ServerURL: baseURL,
SyncedAt: time.Now().Format("2006-01-02 15:04:05"),
InsuranceCount: len(next.InsuranceBaseRatios),
RatioItemCount: len(next.RatioConfigItems),
CoinCorrectionCount: len(next.CoinCorrections),
QuantityItemCount: len(next.QuantityItems),
}, nil
}
// ExportConfig 将当前规则导出为 JSON 文件。
func (a *App) ExportConfig() (ExportResult, error) {
a.mu.RLock()
config := a.config
a.mu.RUnlock()
path, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
Title: "导出规则配置",
DefaultFilename: "hfb-price-config.json",
Filters: []wailsruntime.FileFilter{
{DisplayName: "JSON 文件 (*.json)", Pattern: "*.json"},
},
})
if err != nil {
return ExportResult{}, err
}
if strings.TrimSpace(path) == "" {
return ExportResult{Cancelled: true}, nil
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return ExportResult{}, fmt.Errorf("生成配置失败:%w", err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil {
return ExportResult{}, fmt.Errorf("写入配置失败:%w", err)
}
return ExportResult{Path: path}, nil
}
// ImportConfig 从 JSON 文件导入规则。
func (a *App) ImportConfig() (ImportResult, error) {
path, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "导入规则配置",
Filters: []wailsruntime.FileFilter{
{DisplayName: "JSON 文件 (*.json)", Pattern: "*.json"},
},
})
if err != nil {
return ImportResult{}, err
}
if strings.TrimSpace(path) == "" {
return ImportResult{Cancelled: true}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return ImportResult{}, fmt.Errorf("读取配置失败:%w", err)
}
var config pricing.Config
if err := json.Unmarshal(data, &config); err != nil {
return ImportResult{}, fmt.Errorf("配置格式不正确:%w", err)
}
config = pricing.Normalize(config)
if err := a.persist(config); err != nil {
return ImportResult{}, err
}
return ImportResult{Config: config, Path: path}, nil
}
// OpenConfigFolder 打开本地配置文件所在目录。
func (a *App) OpenConfigFolder() error {
a.mu.RLock()
path := a.configPath
a.mu.RUnlock()
if path == "" {
return errors.New("配置路径尚未初始化")
}
dir := filepath.Dir(path)
var command *exec.Cmd
switch goruntime.GOOS {
case "windows":
command = exec.Command("explorer", dir)
case "darwin":
command = exec.Command("open", dir)
default:
command = exec.Command("xdg-open", dir)
}
if err := command.Start(); err != nil {
return fmt.Errorf("打开目录失败:%w", err)
}
return nil
}
func (a *App) persist(config pricing.Config) error {
config = pricing.Normalize(config)
a.mu.RLock()
path := a.configPath
a.mu.RUnlock()
if path == "" {
defaultPath, err := defaultConfigPath()
if err != nil {
return fmt.Errorf("无法确定配置目录:%w", err)
}
path = defaultPath
a.mu.Lock()
a.configPath = path
a.mu.Unlock()
}
if err := writeConfigFile(path, config); err != nil {
return fmt.Errorf("保存规则失败:%w", err)
}
a.mu.Lock()
a.config = config
a.loadError = ""
a.mu.Unlock()
return nil
}
func defaultConfigPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, appName, "config.json"), nil
}
func loadConfigFile(path string) (pricing.Config, string) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
config := pricing.DefaultConfig()
if writeErr := writeConfigFile(path, config); writeErr != nil {
return config, "默认规则已加载,但写入本地配置失败:" + writeErr.Error()
}
return config, ""
}
if err != nil {
return pricing.DefaultConfig(), "读取本地配置失败,已使用默认规则:" + err.Error()
}
var config pricing.Config
if err := json.Unmarshal(data, &config); err != nil {
return pricing.DefaultConfig(), "本地配置格式不正确,已使用默认规则:" + err.Error()
}
return pricing.Normalize(config), ""
}
func writeConfigFile(path string, config pricing.Config) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
temp, err := os.CreateTemp(filepath.Dir(path), ".config-*.json")
if err != nil {
return err
}
tempName := temp.Name()
defer os.Remove(tempName)
if _, err := temp.Write(data); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Chmod(tempName, 0o644); err != nil {
return err
}
return os.Rename(tempName, path)
}
func normalizeServerURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("请填写线上地址")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return "", errors.New("线上地址格式不正确")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", errors.New("线上地址只支持 http 或 https")
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
func fetchRemotePublishOptions(ctx context.Context, baseURL string) (remotePublishOptions, error) {
endpoint := baseURL + publishEndpoint
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return remotePublishOptions{}, fmt.Errorf("创建请求失败:%w", err)
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", appName+"/"+appVersion)
client := &http.Client{Timeout: 15 * time.Second}
response, err := client.Do(request)
if err != nil {
return remotePublishOptions{}, fmt.Errorf("连接线上失败:%w", err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
return remotePublishOptions{}, fmt.Errorf("读取线上响应失败:%w", err)
}
if response.StatusCode != http.StatusOK {
return remotePublishOptions{}, fmt.Errorf("线上返回 HTTP %d:%s", response.StatusCode, truncateText(string(body), 160))
}
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return remotePublishOptions{}, fmt.Errorf("线上响应不是有效 JSON:%w", err)
}
if envelope.Code != "ok" {
message := strings.TrimSpace(envelope.Message)
if message == "" {
message = envelope.Code
}
return remotePublishOptions{}, fmt.Errorf("线上返回错误:%s", message)
}
var remote remotePublishOptions
if err := json.Unmarshal(envelope.Data, &remote); err != nil {
return remotePublishOptions{}, fmt.Errorf("解析线上规则失败:%w", err)
}
return remote, nil
}
func mergeRemoteQuantityItems(current, remote []pricing.QuantityItem) []pricing.QuantityItem {
currentByKey := make(map[string]pricing.QuantityItem, len(current))
for _, item := range current {
currentByKey[item.Key] = item
}
merged := make([]pricing.QuantityItem, 0, len(remote))
for _, item := range remote {
if old, ok := currentByKey[item.Key]; ok {
if strings.TrimSpace(item.Unit) == "" {
item.Unit = old.Unit
}
if strings.TrimSpace(item.Placeholder) == "" {
item.Placeholder = old.Placeholder
}
item.Disabled = old.Disabled
}
merged = append(merged, item)
}
if len(merged) == 0 {
return current
}
return merged
}
func truncateText(value string, limit int) string {
value = strings.TrimSpace(value)
if len(value) <= limit {
return value
}
return value[:limit] + "..."
}