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] + "..." }