feat(schedules): 每台服务器可设计划任务,按星期和时区定时发命令、重启、停服、开服或备份,重启和备份前可在游戏内提醒

This commit is contained in:
Lemon-miaow committed 2026-09-27 22:48:49 +08:00
1 parent e8ff8f2fe5
commit 34b81ee8fe
32 files changed
+6011 -27

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
package api
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"felis.lolicon.best/internal/naming"
)
// scheduleRunTimeout bounds the first step of a run somebody asked for. The
// step outlives the request, so a client that hangs up cannot cut a stop off
// halfway.
const scheduleRunTimeout = 30 * time.Second
// scheduleServer resolves {name} for the schedule routes and applies their
// gate: 400 for a malformed name, 404 for a server that does not exist, 403
// for a caller who neither owns it nor is an admin, 503 without a store.
func (a *API) scheduleServer(w http.ResponseWriter, r *http.Request) (*ServerRecord, bool) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return nil, false
}
rec, err := a.Repo.ServerByName(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return nil, false
}
if !a.isOwnerOrAdmin(principalFromContext(r.Context()), rec) {
writeError(w, r, errForbidden)
return nil, false
}
if a.Schedules == nil {
writeError(w, r, newError(http.StatusServiceUnavailable, "schedules_unavailable",
"scheduled tasks are not configured"))
return nil, false
}
return rec, true
}
// scheduleID parses {id}.
func scheduleID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_id", "invalid schedule id"))
return 0, false
}
return id, true
}
// writeScheduleError maps the store's schedule errors.
func (a *API) writeScheduleError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, ErrScheduleRunning):
writeError(w, r, newError(http.StatusConflict, "schedule_running",
"this schedule is running; try again once the run finishes"))
case errors.Is(err, ErrScheduleLimit):
writeError(w, r, newError(http.StatusConflict, "schedule_limit",
"a server can have at most %d scheduled tasks", maxSchedulesPerServer))
default:
a.writeLookupError(w, r, err)
}
}
// auditSchedule records a change to a schedule by the signed-in caller.
func (a *API) auditSchedule(r *http.Request, action string, s *Schedule) {
p := principalFromContext(r.Context())
e := AuditEntry{Actor: auditActor(p), Action: action, ServerName: s.Server,
Payload: auditPayload(map[string]any{"schedule": s.ID, "action": s.Action})}
if p != nil {
e.ActorUserID = p.UserID
}
a.auditEntry(r, e)
}
// handleListSchedules serves GET /servers/{name}/schedules.
func (a *API) handleListSchedules(w http.ResponseWriter, r *http.Request) {
rec, ok := a.scheduleServer(w, r)
if !ok {
return
}
list, err := a.Schedules.ListSchedules(r.Context(), rec.Name)
if err != nil {
writeError(w, r, err)
return
}
if list == nil {
list = []Schedule{}
}
writeJSON(w, http.StatusOK, map[string]any{"server": rec.Name, "schedules": list, "limit": maxSchedulesPerServer})
}
// handleCreateSchedule serves POST /servers/{name}/schedules. The schedule
// belongs to the server's current owner (see Schedule.OwnerID).
func (a *API) handleCreateSchedule(w http.ResponseWriter, r *http.Request) {
rec, ok := a.scheduleServer(w, r)
if !ok {
return
}
var in scheduleInput
if err := decodeJSON(w, r, &in); err != nil {
writeError(w, r, err)
return
}
s := &Schedule{Server: rec.Name, OwnerID: rec.OwnerID, CreatedBy: auditActor(principalFromContext(r.Context()))}
if err := in.apply(s); err != nil {
writeError(w, r, err)
return
}
s.NextRunAt = a.firstRun(s)
if err := a.Schedules.CreateSchedule(r.Context(), s, maxSchedulesPerServer); err != nil {
a.writeScheduleError(w, r, err)
return
}
a.auditSchedule(r, "schedule.create", s)
writeJSON(w, http.StatusCreated, s)
}
// handleUpdateSchedule serves PUT /servers/{name}/schedules/{id}: new settings,
// and the schedule passes to the server's current owner.
func (a *API) handleUpdateSchedule(w http.ResponseWriter, r *http.Request) {
rec, ok := a.scheduleServer(w, r)
if !ok {
return
}
id, ok := scheduleID(w, r)
if !ok {
return
}
var in scheduleInput
if err := decodeJSON(w, r, &in); err != nil {
writeError(w, r, err)
return
}
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
if err != nil {
a.writeScheduleError(w, r, err)
return
}
if err := in.apply(s); err != nil {
writeError(w, r, err)
return
}
s.OwnerID, s.NextRunAt = rec.OwnerID, a.firstRun(s)
if err := a.Schedules.UpdateSchedule(r.Context(), s); err != nil {
a.writeScheduleError(w, r, err)
return
}
a.auditSchedule(r, "schedule.update", s)
writeJSON(w, http.StatusOK, s)
}
// handleDeleteSchedule serves DELETE /servers/{name}/schedules/{id}.
func (a *API) handleDeleteSchedule(w http.ResponseWriter, r *http.Request) {
rec, ok := a.scheduleServer(w, r)
if !ok {
return
}
id, ok := scheduleID(w, r)
if !ok {
return
}
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
if err != nil {
a.writeScheduleError(w, r, err)
return
}
if err := a.Schedules.DeleteSchedule(r.Context(), rec.Name, id); err != nil {
a.writeScheduleError(w, r, err)
return
}
a.auditSchedule(r, "schedule.delete", s)
w.WriteHeader(http.StatusNoContent)
}
// handleRunSchedule serves POST /servers/{name}/schedules/{id}/run: the run
// starts now, without the players' warning, and the next scheduled run stays
// where it is. The answer is the schedule after the run's first step; a restart
// or backup goes on in the background.
func (a *API) handleRunSchedule(w http.ResponseWriter, r *http.Request) {
rec, ok := a.scheduleServer(w, r)
if !ok {
return
}
id, ok := scheduleID(w, r)
if !ok {
return
}
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
if err != nil {
a.writeScheduleError(w, r, err)
return
}
if s.OwnerID != rec.OwnerID {
writeError(w, r, newError(http.StatusConflict, "schedule_stale",
"the server has a new owner since this schedule was saved; save it again first"))
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), scheduleRunTimeout)
defer cancel()
now := a.now()
claimed, err := a.Schedules.ClaimScheduleRun(ctx, id, nil, nil, now)
if err != nil {
writeError(w, r, err)
return
}
if !claimed {
a.writeScheduleError(w, r, ErrScheduleRunning)
return
}
a.auditSchedule(r, "schedule.run_now", s)
if err := a.beginRun(ctx, s); err != nil {
writeError(w, r, err)
return
}
after, err := a.Schedules.GetSchedule(ctx, rec.Name, id)
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusAccepted, after)
}
// firstRun is the next run of a schedule just saved, nil while it is disabled.
func (a *API) firstRun(s *Schedule) *time.Time {
if !s.Enabled {
return nil
}
return ptrTime(s.nextRun(a.now()))
}