fix(panel): 服务器列表的归属与可认领改由后端按账号判定,无邮箱管理员也能认出自己的服务器,所有者查询失败时显示未知且不给认领

This commit is contained in:
Lemon-miaow committed 2026-09-25 12:36:38 +08:00
1 parent 06d5e652c6
commit bb9c2168df
14 files changed
+323 -70

No files matched your search

+58 -23
View File
@@ -33,10 +33,10 @@ type fakeRepo struct {
// the account_links-bridged web view of the same data.
allowUUID map[string]map[string]bool
mine map[string][]MyServerView
// owners mirrors the ServerOwners join (name -> owner display identity); only
// claimed servers appear. ownersErr forces the lookup to fail so a test can
// prove the fleet read degrades to owner-less rows rather than 500ing.
owners map[string]string
// owners mirrors the ServerOwners join (name -> claim state); a live unclaimed
// server appears with an empty OwnerID. ownersErr forces the lookup to fail so
// a test can prove the fleet read degrades rather than 500ing.
owners map[string]ServerOwnership
ownersErr error
claimOK map[string]bool // name -> claim succeeds; absent name -> ErrNotFound
// claimQuotaRefuse simulates ClaimServer's atomic quota gate (audit #4)
@@ -255,7 +255,7 @@ func newFakeRepo() *fakeRepo {
linked: map[string]bool{}, quota: map[string]bool{},
allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{},
mine: map[string][]MyServerView{},
owners: map[string]string{},
owners: map[string]ServerOwnership{},
claimOK: map[string]bool{}, claimQuotaRefuse: map[string]bool{},
serverResources: map[string]ResourceSpec{}, resourceUpdates: map[string]ResourceSpec{},
seeded: map[string]bool{}, aliases: map[string]string{},
@@ -750,7 +750,7 @@ func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error
// into the slice it gets.
return append([]MyServerView(nil), f.mine[u]...), nil
}
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) {
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]ServerOwnership, error) {
if f.ownersErr != nil {
return nil, f.ownersErr
}
@@ -1971,11 +1971,13 @@ func TestFleetAdminRead(t *testing.T) {
})
// fleetRow mirrors the on-the-wire fleetServerView: the lifecycle fields plus
// the presentational owner join. A server absent from ServerOwners (unclaimed)
// or a failed lookup must serialize owner as "" (omitempty drops it).
// the ownership join.
type fleetRow struct {
Name string `json:"name"`
Owner string `json:"owner"`
Name string `json:"name"`
Owner string `json:"owner"`
Owned bool `json:"owned"`
Claimable bool `json:"claimable"`
OwnerUnknown bool `json:"ownerUnknown"`
}
adminAPI := func(repo *fakeRepo) *API {
api := newTestAPI(repo, cl)
@@ -2042,33 +2044,66 @@ func TestFleetAdminRead(t *testing.T) {
}
})
t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) {
t.Run("ownership comes from the account id", func(t *testing.T) {
repo := newFakeRepo()
// Only "survival" is claimed; "creative"/"skyblock" stay unowned.
repo.owners["survival"] = "[email protected]"
byName := map[string]string{}
// The caller (a1) owns "survival" but has no email, so its display is the
// username; "creative" belongs to someone else; "skyblock" is unclaimed.
repo.owners["survival"] = ServerOwnership{OwnerID: "a1", Owner: "a1-username"}
repo.owners["creative"] = ServerOwnership{OwnerID: "u2", Owner: "[email protected]"}
repo.owners["skyblock"] = ServerOwnership{}
byName := map[string]fleetRow{}
for _, r := range readFleet(t, adminAPI(repo)) {
byName[r.Name] = r.Owner
byName[r.Name] = r
}
if byName["survival"] != "[email protected]" {
t.Fatalf("survival owner = %q, want [email protected]", byName["survival"])
want := map[string]fleetRow{
"survival": {Name: "survival", Owner: "a1-username", Owned: true},
"creative": {Name: "creative", Owner: "[email protected]"},
"skyblock": {Name: "skyblock", Claimable: true},
}
if byName["creative"] != "" {
t.Fatalf("creative owner = %q, want empty (unclaimed)", byName["creative"])
for name, w := range want {
if byName[name] != w {
t.Errorf("%s = %+v, want %+v", name, byName[name], w)
}
}
})
t.Run("owner lookup failure degrades to owner-less rows", func(t *testing.T) {
t.Run("a server without a business row cannot be claimed", func(t *testing.T) {
// A CRD the servers table does not know (or a soft-deleted row) answers a
// claim with 404, so the row must not offer one.
rows := readFleet(t, adminAPI(newFakeRepo()))
for _, r := range rows {
if r.Claimable || r.Owned || r.OwnerUnknown {
t.Errorf("%s = %+v, want no claim state (no business row)", r.Name, r)
}
}
})
t.Run("system services are never claimable", func(t *testing.T) {
sysCl := newFakeCluster()
sysCl.list = []ServerInfo{{Name: "lobby", Phase: "Running", Ready: true}}
repo := newFakeRepo()
repo.owners["survival"] = "[email protected]" // would merge, but the lookup errors
repo.owners["lobby"] = ServerOwnership{}
api := newTestAPI(repo, sysCl)
api.External = staticExternal{p: &Principal{UserID: "a1", Email: "[email protected]",
Role: "admin", ViaAdminAccess: true}}
rows := readFleet(t, api)
if len(rows) != 1 || rows[0].Claimable {
t.Fatalf("rows = %+v, want lobby present and not claimable", rows)
}
})
t.Run("owner lookup failure marks ownership unknown", func(t *testing.T) {
repo := newFakeRepo()
repo.owners["survival"] = ServerOwnership{OwnerID: "u2", Owner: "[email protected]"} // would merge, but the lookup errors
repo.owners["skyblock"] = ServerOwnership{}
repo.ownersErr = fmt.Errorf("postgres unreachable")
rows := readFleet(t, adminAPI(repo)) // must still be 200, not 500
if len(rows) != 3 {
t.Fatalf("servers = %d, want 3 (a Postgres blip must not drop the fleet)", len(rows))
}
for _, r := range rows {
if r.Owner != "" {
t.Fatalf("%s owner = %q, want empty (owner lookup failed → degrade)", r.Name, r.Owner)
if r.Owner != "" || r.Claimable || r.Owned || !r.OwnerUnknown {
t.Fatalf("%s = %+v, want owner unknown and nothing to claim", r.Name, r)
}
}
})
+23 -9
View File
@@ -296,15 +296,20 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
// Owner is presentational and best-effort. The cockpit exists for the lifecycle
// view, so a Postgres hiccup must degrade to owner-less rows, never 500 the whole
// fleet: a lookup error is swallowed and owners stays nil, leaving every row's
// Owner "" (a nil map reads as zero values).
owners, _ := a.Repo.ServerOwners(r.Context())
// Ownership is best-effort. The cockpit exists for the lifecycle view, so a
// Postgres hiccup must never 500 the whole fleet; the rows say the owner is
// unknown instead, and none offers a claim that may already be taken.
p := principalFromContext(r.Context())
owners, err := a.Repo.ServerOwners(r.Context())
unknown := err != nil
views := make([]fleetServerView, len(servers))
for i, s := range servers {
views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name],
System: naming.IsSystemServer(s.Name)}
o, known := owners[s.Name]
system := naming.IsSystemServer(s.Name)
views[i] = fleetServerView{ServerInfo: s, Owner: o.Owner, System: system,
Owned: o.OwnerID != "" && o.OwnerID == p.UserID,
Claimable: known && o.OwnerID == "" && !system,
OwnerUnknown: unknown}
}
writeJSON(w, http.StatusOK, map[string]any{"servers": views})
}
@@ -316,9 +321,18 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
type fleetServerView struct {
ServerInfo
// Owner is the claiming user's display identity (email, or username when the
// address is absent), or "" when the server is unclaimed or the best-effort
// owner lookup failed — the cockpit renders "" as "unclaimed".
// address is absent), or "" when the server is unclaimed or the owner lookup
// failed (OwnerUnknown tells the two apart).
Owner string `json:"owner,omitempty"`
// Owned is true when the caller claimed this server, decided by account id so
// an owner without an email is still recognized.
Owned bool `json:"owned"`
// Claimable is true for a live, unclaimed, non-system server: the same rule
// ClaimServer enforces. It is false whenever ownership is unknown.
Claimable bool `json:"claimable"`
// OwnerUnknown is true when the best-effort owner lookup failed, so an empty
// Owner says nothing about whether the server is claimed.
OwnerUnknown bool `json:"ownerUnknown,omitempty"`
// System marks a platform-provisioned system service (the login gate and the
// lobby, naming.IsSystemServer). Their names are reserved, so every per-server
// API route rejects them — the cockpit must render them read-only rather than
+14 -14
View File
@@ -609,29 +609,29 @@ func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView,
return out, rows.Err()
}
// ServerOwners returns name -> owner display identity for every currently-owned,
// non-deleted server (the SysAdmin cockpit's fleet read). The INNER JOIN drops
// unclaimed servers (owner_id NULL) and the deleted_at filter drops soft-deleted
// ones, so the map holds only servers that have a live owner — the cockpit reads a
// missing key as "no owner". The display value prefers the recognizable email
// (the same identity the audit log records as the human actor, §6) and falls back
// to the never-NULL username when the address is absent.
func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]string, error) {
const q = `SELECT s.name, COALESCE(NULLIF(u.email, ''), u.username)
FROM servers s JOIN users u ON u.id = s.owner_id
// ServerOwners returns name -> claim state for every non-deleted server (the
// SysAdmin cockpit's fleet read). The LEFT JOIN keeps unclaimed servers (owner_id
// NULL) with an empty OwnerID, and the deleted_at filter drops soft-deleted ones.
// The display value prefers the recognizable email (the same identity the audit
// log records as the human actor, §6) and falls back to the never-NULL username
// when the address is absent.
func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]ServerOwnership, error) {
const q = `SELECT s.name, COALESCE(s.owner_id, ''), COALESCE(NULLIF(u.email, ''), u.username, '')
FROM servers s LEFT JOIN users u ON u.id = s.owner_id
WHERE s.deleted_at IS NULL`
rows, err := p.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]string)
out := make(map[string]ServerOwnership)
for rows.Next() {
var name, owner string
if err := rows.Scan(&name, &owner); err != nil {
var name string
var o ServerOwnership
if err := rows.Scan(&name, &o.OwnerID, &o.Owner); err != nil {
return nil, err
}
out[name] = owner
out[name] = o
}
return out, rows.Err()
}
+19 -9
View File
@@ -38,6 +38,17 @@ type MyServerView struct {
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
}
// ServerOwnership is one live server's claim state as the fleet read joins it.
type ServerOwnership struct {
// OwnerID is the claiming account, "" while the server is unclaimed. The fleet
// compares it with the caller's id: an account without an email shows its
// username as Owner, so the display text cannot say whose server it is.
OwnerID string
// Owner is the claiming account's display identity (email, or username when
// the address is absent), "" while unclaimed.
Owner string
}
// AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
// a verified email or the username for people (auditActor), the component name
// for internal callers. ActorUserID is the account that acted, the column to
@@ -286,15 +297,14 @@ type Repo interface {
RecordJoin(ctx context.Context, name, mcUUID string) error
// MyServers lists the servers a user owns or may claim.
MyServers(ctx context.Context, userID string) ([]MyServerView, error)
// ServerOwners maps each currently-owned server to its owner's display identity
// (email, or username when the address is absent), for the SysAdmin cockpit's
// fleet read. It is a READ-ONLY presentational join: owner stays authored in
// Postgres (§6 business authority) and is never written back to the CRD, so this
// does not breach §1's store-of-record split. Unclaimed and soft-deleted servers
// are simply absent from the map, so a missing key reads as "no owner". The
// cockpit treats it as best-effort — a lookup error degrades to owner-less rows
// rather than failing the fleet read — so callers may ignore the error.
ServerOwners(ctx context.Context) (map[string]string, error)
// ServerOwners maps every live server to its claim state, for the SysAdmin
// cockpit's fleet read. It is a READ-ONLY join: owner stays authored in Postgres
// (§6 business authority) and is never written back to the CRD, so this does not
// breach §1's store-of-record split. An unclaimed server is present with an empty
// OwnerID; a soft-deleted one, or a CRD with no business row, is absent, and
// cannot be claimed. The cockpit treats it as best-effort: a lookup error leaves
// ownership unknown rather than failing the fleet read.
ServerOwners(ctx context.Context) (map[string]ServerOwnership, error)
// AllBackups lists every present world backup, newest first (spec §7 GET
// /backups, admin scope). Expired/deleted rows are never returned.
AllBackups(ctx context.Context) ([]BackupView, error)