From 1b59961ce2385252e3346ea6107b15a2c1feafe6 Mon Sep 17 00:00:00 2001 From: kboz Date: Sun, 27 Sep 2026 22:36:34 +0200 Subject: feat(backend): port route-planning backend to Go Add a Go implementation mirroring the Python pipeline (loader, graph, pathfinder, arbitrage, market, route, manager) under internal/core, with ESI client/API integration and setup endpoints. Reuses backend/src as the working directory so existing data/cache paths keep working. --- backend-go/internal/esi/api.go | 215 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 backend-go/internal/esi/api.go (limited to 'backend-go/internal/esi/api.go') diff --git a/backend-go/internal/esi/api.go b/backend-go/internal/esi/api.go new file mode 100644 index 0000000..706ea32 --- /dev/null +++ b/backend-go/internal/esi/api.go @@ -0,0 +1,215 @@ +package esi + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "time" +) + +type WaypointResult struct { + RequestedCount int `json:"requested_count"` + SuccessCount int `json:"success_count"` + AppliedWaypoints []int `json:"applied_waypoints"` + FailedWaypoints []int `json:"failed_waypoints"` + Errors []string `json:"errors"` + Success bool `json:"success"` +} + +type WalletTransaction struct { + TransactionID int `json:"transaction_id"` + Date time.Time `json:"date"` + LocationID int `json:"location_id"` + TypeID int `json:"type_id"` + UnitPrice float64 `json:"unit_price"` + Quantity int `json:"quantity"` + ClientID int `json:"client_id"` + IsBuy bool `json:"is_buy"` + IsPersonal bool `json:"is_personal"` + JournalRefID int `json:"journal_ref_id"` +} + +type LocationData struct { + SolarSystemID int `json:"solar_system_id"` + StationID int `json:"station_id"` +} + +func (c *EsiClient) GetLocation() (*LocationData, error) { + characterIDStr := os.Getenv("character_id") + if characterIDStr == "" { + return nil, fmt.Errorf("character_id not set in environment variables") + } + characterID, _ := strconv.Atoi(characterIDStr) + + endpoint := fmt.Sprintf("/characters/%d/location/?datasource=tranquility", characterID) + resp, err := c.doRequest("GET", endpoint, nil, true) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get location, status: %d body: %s", resp.StatusCode, string(body)) + } + +var data LocationData + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + return &data, nil +} + +func (c *EsiClient) SetWaypoints(waypoints []int) (*WaypointResult, error) { + res := &WaypointResult{ + RequestedCount: len(waypoints), + Success: true, + } + + for i, wp := range waypoints { + clearOther := "false" + if i == 0 { + clearOther = "true" + } + + endpoint := fmt.Sprintf("/ui/autopilot/waypoint/?add_to_beginning=false&clear_other_waypoints=%s&destination_id=%d&datasource=tranquility", clearOther, wp) + + resp, err := c.doRequest("POST", endpoint, nil, true) + if err != nil { + res.FailedWaypoints = append(res.FailedWaypoints, wp) + res.Errors = append(res.Errors, err.Error()) + res.Success = false + continue + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + res.SuccessCount++ + res.AppliedWaypoints = append(res.AppliedWaypoints, wp) + } else { + body, _ := io.ReadAll(resp.Body) + res.Errors = append(res.Errors, fmt.Sprintf("status %d body %s", resp.StatusCode, string(body))) + res.FailedWaypoints = append(res.FailedWaypoints, wp) + res.Success = false + } + resp.Body.Close() + time.Sleep(50 * time.Millisecond) // Rate limiting + } + + res.Success = len(res.FailedWaypoints) == 0 + return res, nil +} + +func (c *EsiClient) GetWalletTransactions(fromID *int) ([]WalletTransaction, error) { + characterIDStr := os.Getenv("character_id") + if characterIDStr == "" { + return nil, fmt.Errorf("character_id not set in environment variables") + } + characterID, _ := strconv.Atoi(characterIDStr) + endpoint := fmt.Sprintf("/characters/%d/wallet/transactions/?datasource=tranquility", characterID) + if fromID != nil { + endpoint += fmt.Sprintf("&from_id=%d", *fromID) + } + + resp, err := c.doRequest("GET", endpoint, nil, true) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get transactions, status: %d body: %s", resp.StatusCode, string(body)) + } + + var baseTx []struct { + TransactionID int `json:"transaction_id"` + Date string `json:"date"` + LocationID int `json:"location_id"` + TypeID int `json:"type_id"` + UnitPrice float64 `json:"unit_price"` + Quantity int `json:"quantity"` + ClientID int `json:"client_id"` + IsBuy bool `json:"is_buy"` + IsPersonal bool `json:"is_personal"` + JournalRefID int `json:"journal_ref_id"` + } + + if err := json.NewDecoder(resp.Body).Decode(&baseTx); err != nil { + return nil, err + } + + var results []WalletTransaction + for _, bt := range baseTx { + // ESI dates look like `2026-04-26T01:23:45Z` + t, _ := time.Parse(time.RFC3339, bt.Date) + results = append(results, WalletTransaction{ + TransactionID: bt.TransactionID, + Date: t, + LocationID: bt.LocationID, + TypeID: bt.TypeID, + UnitPrice: bt.UnitPrice, + Quantity: bt.Quantity, + ClientID: bt.ClientID, + IsBuy: bt.IsBuy, + IsPersonal: bt.IsPersonal, + JournalRefID: bt.JournalRefID, + }) + } + return results, nil +} + +func (c *EsiClient) OpenMarketWindow(typeID int) error { + endpoint := fmt.Sprintf("/ui/openwindow/marketdetails/?datasource=tranquility&type_id=%d", typeID) + resp, err := c.doRequest("POST", endpoint, nil, true) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to open market window, status: %d body: %s", resp.StatusCode, string(body)) +} + +func (c *EsiClient) GetRegionOrders(regionID int, page int) ([]map[string]interface{}, string, int, error) { + endpoint := fmt.Sprintf("/markets/%d/orders/?datasource=tranquility&order_type=all&page=%d", regionID, page) + resp, err := c.doRequest("GET", endpoint, nil, true) + if err != nil { + return nil, "", 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, "", 0, fmt.Errorf("failed to get region orders, status: %d body: %s", resp.StatusCode, string(body)) + } + + var orders []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&orders); err != nil { + return nil, "", 0, err + } + + expires := resp.Header.Get("Expires") + totalPagesStr := resp.Header.Get("X-Pages") + totalPages, _ := strconv.Atoi(totalPagesStr) + if totalPages == 0 { + totalPages = 1 + } + + // Add RegionID and drop 0 volume + var filtered []map[string]interface{} + for _, o := range orders { + volRemain, _ := o["volume_remain"].(float64) + if volRemain > 0 { + o["region_id"] = regionID + filtered = append(filtered, o) + } + } + + return filtered, expires, totalPages, nil +} -- cgit v1.3