fix(menu): 大厅菜单按玩家标出每台服能否启动及原因,自己的服排前面,状态改成中文

This commit is contained in:
Lemon-miaow committed 2026-09-27 05:02:33 +08:00
1 parent 82310d02ec
commit 4ae64cc2e8
21 files changed
+1081 -136

No files matched your search

+2
View File
@@ -399,6 +399,8 @@ func (a *API) internalAPIRoutes() []apiRoute {
// list/status views never carry.
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", Callers: proxy, h: a.handleInternalClaim},
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", Callers: proxy, h: a.handleInternalMenuStatus},
// What this player may start, for every tile at once: one call per menu open.
{Method: "GET", Pattern: "/api/v1/internal/player/menu-access/{mc_uuid}", Callers: proxy, h: a.handleInternalMenuAccess},
// Account linking (spec §10): the in-game /link side mints a one-time code for a
// verified UUID. Internal-only — the code is born from an online-mode UUID the
// web never holds (account_link_codes has no user_id column).
+130 -30
View File
@@ -343,6 +343,80 @@ func (a *API) handleInternalMenuStatus(w http.ResponseWriter, r *http.Request) {
})
}
// Menu access verdicts: what a lobby menu click on a server that is not up would
// meet for one player (handleInternalMenuAccess).
const (
menuRetiring = "retiring" // given up or being deleted: nobody starts it
menuStartFailed = "start_failed" // automatic restarts spent: waits for its owner
menuOwner = "owner" // the player's own server
menuWake = "wake" // the policy lets this player start it
menuOwnerOnly = "owner_only" // ownerOnly (or unset): only its owner starts it
menuAllowlist = "allowlist" // allowlist, and the player is not on it
)
// handleInternalMenuAccess answers the lobby menu's per-player question (spec §12):
// for every user server, whether this verified UUID may start it and why not. The
// tiles' live state stays in the shared per-server projection (…/menu); this is one
// call per menu open, so a lobby full of players still reads each server's status
// once. A server that is up is open to every linked player (handleInternalWake), so
// the lobby shows Join there whatever the verdict. The verdicts follow the wake's
// own gates with the transient refusals (cooldown, the running-server cap) left out,
// since a retry gets past those. Retiring comes first: nobody may start such a
// server, so it is the reason a stranger is shown too.
func (a *API) handleInternalMenuAccess(w http.ResponseWriter, r *http.Request) {
mcUUID := r.PathValue("mc_uuid")
if mcUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return
}
infos, err := a.Cluster.ListServers(r.Context())
if err != nil {
writeError(w, r, err)
return
}
owners, err := a.Repo.ServerOwners(r.Context())
if err != nil {
writeError(w, r, err)
return
}
standing, err := a.standingByUUID(r.Context(), mcUUID)
if err != nil {
writeError(w, r, err)
return
}
verdicts := make(map[string]string, len(infos))
for i := range infos {
info := &infos[i]
if naming.ValidateServerName(info.Name) != nil {
continue // the login gate and the lobby are not menu tiles
}
own := owners[info.Name]
switch {
case own.Retire != nil:
verdicts[info.Name] = menuRetiring
case info.StartGaveUp && info.DesiredState == string(v1alpha1.DesiredRunning):
verdicts[info.Name] = menuStartFailed
case standing.userID != "" && standing.userID == own.OwnerID:
verdicts[info.Name] = menuOwner
default:
ok, err := a.policyAdmits(r.Context(), mcUUID, standing, info, own.OwnerID)
if err != nil {
writeError(w, r, err)
return
}
switch {
case ok:
verdicts[info.Name] = menuWake
case info.AutostartPolicy == string(v1alpha1.AutostartAllowlist):
verdicts[info.Name] = menuAllowlist
default:
verdicts[info.Name] = menuOwnerOnly
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"servers": verdicts})
}
// authorizeWakeByUUID is the internal-face counterpart of authorizeWake (spec
// §9.4): it applies the autostartPolicy gate for a wake driven by velocity, where
// the joining player is known only by their verified online-mode UUID rather than
@@ -358,40 +432,66 @@ func (a *API) authorizeWakeByUUID(ctx context.Context, mcUUID string, info *Serv
if info.AutostartPolicy == string(v1alpha1.AutostartPublic) {
return nil
}
// Resolve the UUID to its linked user once; staff role or ownership grants
// the bypass. A missing link is not an error here — it just means "no
// standing", and a link pointing at a vanished user reads the same way.
switch userID, err := a.Repo.UserByMCUUID(ctx, mcUUID); {
case err == nil:
switch u, err := a.Repo.UserByID(ctx, userID); {
case err == nil:
if staffRole(u.Role) {
return nil
}
case !errors.Is(err, ErrNotFound):
return err
}
if rec != nil && rec.OwnerID != "" && userID == rec.OwnerID {
return nil
}
case errors.Is(err, ErrNotFound):
// unlinked UUID → fall through to the policy gate
default:
s, err := a.standingByUUID(ctx, mcUUID)
if err != nil {
return err
}
switch info.AutostartPolicy {
case string(v1alpha1.AutostartAllowlist):
ok, err := a.Repo.UUIDInAllowlist(ctx, info.Name, mcUUID)
if err != nil {
return err
}
if ok {
return nil
}
return errForbidden
default: // ownerOnly or unset → only the owner (handled above) may wake
owner := ""
if rec != nil {
owner = rec.OwnerID
}
ok, err := a.policyAdmits(ctx, mcUUID, s, info, owner)
if err != nil {
return err
}
if !ok {
return errForbidden
}
return nil
}
// uuidStanding is what a verified in-game UUID brings to the autostartPolicy gate:
// the user it is linked to ("" when unlinked) and whether that user is staff.
type uuidStanding struct {
userID string
staff bool
}
// standingByUUID resolves the UUID to its linked user once. A missing link is not
// an error here — it just means "no standing", and a link pointing at a vanished
// user reads as the link without the staff role.
func (a *API) standingByUUID(ctx context.Context, mcUUID string) (uuidStanding, error) {
userID, err := a.Repo.UserByMCUUID(ctx, mcUUID)
switch {
case errors.Is(err, ErrNotFound):
return uuidStanding{}, nil
case err != nil:
return uuidStanding{}, err
}
switch u, err := a.Repo.UserByID(ctx, userID); {
case err == nil:
return uuidStanding{userID: userID, staff: staffRole(u.Role)}, nil
case errors.Is(err, ErrNotFound):
return uuidStanding{userID: userID}, nil
default:
return uuidStanding{}, err
}
}
// policyAdmits is the autostartPolicy gate itself: public admits anyone, staff and
// the owner (ownerID, "" while unclaimed) pass every policy, allowlist admits a
// listed UUID, and ownerOnly or unset admits no one else.
func (a *API) policyAdmits(ctx context.Context, mcUUID string, s uuidStanding, info *ServerInfo, ownerID string) (bool, error) {
if info.AutostartPolicy == string(v1alpha1.AutostartPublic) || s.staff {
return true, nil
}
if s.userID != "" && s.userID == ownerID {
return true, nil
}
if info.AutostartPolicy == string(v1alpha1.AutostartAllowlist) {
return a.Repo.UUIDInAllowlist(ctx, info.Name, mcUUID)
}
return false, nil
}
// writeLookupError maps a repo/cluster lookup error onto an HTTP status: a
+118
View File
@@ -2,9 +2,13 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
)
// The internal-face claim + menu pair (spec §9.3, §12) is what velocity drives for
@@ -217,3 +221,117 @@ func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) {
}
t.Fatalf("no velocity/internal claim audit for %q in %+v", name, f.audits)
}
func internalMenuAccess(api *API, uuid string) *httptest.ResponseRecorder {
return do(api.InternalHandler(), "GET", "/api/v1/internal/player/menu-access/"+uuid, "", nil)
}
// The menu-access verdicts tell the lobby, per tile, whether this player may start
// the server and why not, with the wake's own gates behind each one.
func TestInternalMenuAccess(t *testing.T) {
gone := &RetireState{RequestedAt: time.Now()}
setup := func() (*API, *fakeRepo) {
repo := newFakeRepo()
cl := newFakeCluster()
running := string(v1alpha1.DesiredRunning)
cl.list = []ServerInfo{
{Name: "pub", AutostartPolicy: "public"},
{Name: "mine", AutostartPolicy: "ownerOnly"},
{Name: "theirs", AutostartPolicy: "ownerOnly"},
{Name: "unset"},
{Name: "listed", AutostartPolicy: "allowlist"},
{Name: "unlisted", AutostartPolicy: "allowlist"},
{Name: "ownerless", AutostartPolicy: "ownerOnly"},
{Name: "gone", AutostartPolicy: "public"},
{Name: "mine-gone", AutostartPolicy: "ownerOnly"},
{Name: "broken", AutostartPolicy: "public", StartGaveUp: true, DesiredState: running},
{Name: "mine-broken", AutostartPolicy: "ownerOnly", StartGaveUp: true, DesiredState: running},
{Name: "broken-stopped", AutostartPolicy: "public", StartGaveUp: true},
{Name: "lobby", AutostartPolicy: "public"},
}
repo.owners = map[string]ServerOwnership{
"pub": {OwnerID: "u2"},
"mine": {OwnerID: "user1"},
"theirs": {OwnerID: "u2"},
"unset": {OwnerID: "u2"},
"listed": {OwnerID: "u2"},
"unlisted": {OwnerID: "u2"},
"ownerless": {},
"gone": {OwnerID: "u2", Retire: gone},
"mine-gone": {OwnerID: "user1", Retire: gone},
"broken": {OwnerID: "u2"},
"mine-broken": {OwnerID: "user1"},
"broken-stopped": {OwnerID: "u2"},
}
repo.allowUUID["listed"] = map[string]bool{menuUUID: true}
return newTestAPI(repo, cl), repo
}
verdicts := func(t *testing.T, api *API) map[string]any {
t.Helper()
got := decodeMenu(t, internalMenuAccess(api, menuUUID))
servers, ok := got["servers"].(map[string]any)
if !ok {
t.Fatalf("servers = %v, want an object", got["servers"])
}
return servers
}
t.Run("a linked player", func(t *testing.T) {
api, repo := setup()
repo.links[menuUUID] = "user1"
got := verdicts(t, api)
for name, want := range map[string]string{
"pub": "wake",
"mine": "owner",
"theirs": "owner_only",
"unset": "owner_only",
"listed": "wake",
"unlisted": "allowlist",
"ownerless": "owner_only",
"gone": "retiring",
"mine-gone": "retiring",
"broken": "start_failed",
"mine-broken": "start_failed",
"broken-stopped": "wake",
} {
assertEq(t, name, got[name], want)
}
if _, listed := got["lobby"]; listed {
t.Fatal("the lobby is not a menu tile, yet it has a verdict")
}
assertEq(t, "verdict count", len(got), 12)
})
t.Run("staff start any server that is not retiring or failed", func(t *testing.T) {
api, repo := setup()
repo.links[menuUUID] = "a1"
repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Role: "admin"}
got := verdicts(t, api)
assertEq(t, "theirs", got["theirs"], "wake")
assertEq(t, "unlisted", got["unlisted"], "wake")
assertEq(t, "ownerless", got["ownerless"], "wake")
assertEq(t, "gone", got["gone"], "retiring")
})
t.Run("an unlinked UUID owns nothing, not even an ownerless server", func(t *testing.T) {
api, _ := setup()
got := verdicts(t, api)
assertEq(t, "mine", got["mine"], "owner_only")
assertEq(t, "ownerless", got["ownerless"], "owner_only")
assertEq(t, "listed", got["listed"], "wake")
assertEq(t, "pub", got["pub"], "wake")
})
t.Run("a failed read fails the call", func(t *testing.T) {
api, repo := setup()
repo.ownersErr = errors.New("db down")
if w := internalMenuAccess(api, menuUUID); w.Code != http.StatusInternalServerError {
t.Fatalf("owners unreadable: code = %d, want 500 (%s)", w.Code, w.Body.String())
}
api, _ = setup()
api.Cluster.(*fakeCluster).listErr = errors.New("apiserver down")
if w := internalMenuAccess(api, menuUUID); w.Code < 500 {
t.Fatalf("cluster unreadable: code = %d, want 5xx (%s)", w.Code, w.Body.String())
}
})
}