Files
pzmanager/internal/pzconfig/workshop.go
T
mrleo1nidandClaude Opus 5 d0ff64c272 Веб-панель управления сервером Project Zomboid
Менеджер запускает сервер 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>
2026-09-03 22:41:11 +03:00

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
}