Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
4.7 KiB
Go
154 lines
4.7 KiB
Go
package pzconfig
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// workshopAPI — публичный метод Steam, ключ доступа не требуется.
|
|
const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
|
|
|
// WorkshopItem — то, что панель показывает про мод из мастерской.
|
|
type WorkshopItem struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Preview string `json:"preview"`
|
|
Description string `json:"description"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// WorkshopClient запрашивает названия модов у Steam и кеширует их: список
|
|
// модов на странице обновляется часто, а названия не меняются.
|
|
type WorkshopClient struct {
|
|
HTTP *http.Client
|
|
|
|
mu sync.Mutex
|
|
cache map[string]cachedItem
|
|
}
|
|
|
|
type cachedItem struct {
|
|
item WorkshopItem
|
|
fetched time.Time
|
|
}
|
|
|
|
// cacheTTL — как долго доверять закешированному названию мода.
|
|
const cacheTTL = 12 * time.Hour
|
|
|
|
// NewWorkshopClient создаёт клиент со своим HTTP-таймаутом.
|
|
func NewWorkshopClient() *WorkshopClient {
|
|
return &WorkshopClient{
|
|
HTTP: &http.Client{Timeout: 15 * time.Second},
|
|
cache: make(map[string]cachedItem),
|
|
}
|
|
}
|
|
|
|
// Details запрашивает описание модов по их Workshop ID. Если Steam недоступен,
|
|
// возвращает записи с заполненным полем Error — панель всё равно покажет
|
|
// список, просто без названий.
|
|
func (c *WorkshopClient) Details(ctx context.Context, ids []string) []WorkshopItem {
|
|
out := make([]WorkshopItem, 0, len(ids))
|
|
missing := make([]string, 0, len(ids))
|
|
|
|
c.mu.Lock()
|
|
for _, id := range ids {
|
|
if entry, ok := c.cache[id]; ok && time.Since(entry.fetched) < cacheTTL {
|
|
out = append(out, entry.item)
|
|
continue
|
|
}
|
|
missing = append(missing, id)
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
if len(missing) > 0 {
|
|
fetched, err := c.fetch(ctx, missing)
|
|
if err != nil {
|
|
for _, id := range missing {
|
|
out = append(out, WorkshopItem{ID: id, Error: err.Error()})
|
|
}
|
|
} else {
|
|
c.mu.Lock()
|
|
for _, item := range fetched {
|
|
c.cache[item.ID] = cachedItem{item: item, fetched: time.Now()}
|
|
}
|
|
c.mu.Unlock()
|
|
out = append(out, fetched...)
|
|
}
|
|
}
|
|
|
|
// Возвращаем в том же порядке, в каком просили.
|
|
byID := make(map[string]WorkshopItem, len(out))
|
|
for _, item := range out {
|
|
byID[item.ID] = item
|
|
}
|
|
ordered := make([]WorkshopItem, 0, len(ids))
|
|
for _, id := range ids {
|
|
if item, ok := byID[id]; ok {
|
|
ordered = append(ordered, item)
|
|
continue
|
|
}
|
|
ordered = append(ordered, WorkshopItem{ID: id, Error: "нет данных"})
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopItem, error) {
|
|
form := url.Values{}
|
|
form.Set("itemcount", strconv.Itoa(len(ids)))
|
|
for n, id := range ids {
|
|
form.Set(fmt.Sprintf("publishedfileids[%d]", n), id)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, workshopAPI,
|
|
strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Steam недоступен: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("Steam ответил %s", resp.Status)
|
|
}
|
|
|
|
var payload struct {
|
|
Response struct {
|
|
PublishedFileDetails []struct {
|
|
PublishedFileID string `json:"publishedfileid"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
PreviewURL string `json:"preview_url"`
|
|
Result int `json:"result"`
|
|
} `json:"publishedfiledetails"`
|
|
} `json:"response"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
return nil, fmt.Errorf("ответ Steam не разобран: %w", err)
|
|
}
|
|
|
|
items := make([]WorkshopItem, 0, len(payload.Response.PublishedFileDetails))
|
|
for _, d := range payload.Response.PublishedFileDetails {
|
|
item := WorkshopItem{ID: d.PublishedFileID, Title: d.Title, Preview: d.PreviewURL}
|
|
// result == 1 означает "найдено"; всё остальное — удалён или скрыт.
|
|
if d.Result != 1 {
|
|
item.Error = "мод не найден в мастерской"
|
|
}
|
|
if len(d.Description) > 400 {
|
|
d.Description = d.Description[:400] + "…"
|
|
}
|
|
item.Description = d.Description
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|