DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+636
View File
@@ -0,0 +1,636 @@
package logic
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"longheng.io/server/internal/platform/eventlog"
"longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/reference/player"
"longheng.io/server/internal/runtime/leaderboard"
"longheng.io/server/internal/runtime/moderation"
)
type lbRepairReq struct {
Action string `json:"action"`
LeaderboardID string `json:"leaderboard_id"`
OwnerID string `json:"owner_id,omitempty"`
Title string `json:"title,omitempty"`
SortOrder string `json:"sort_order,omitempty"`
Operator string `json:"operator,omitempty"`
MaxSize int `json:"max_size,omitempty"`
Score int64 `json:"score,omitempty"`
Subscore int64 `json:"subscore,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type sanctionRequest struct {
ID string `json:"id,omitempty"`
Subject string `json:"subject"`
Scope string `json:"scope,omitempty"`
Kind string `json:"kind,omitempty"`
Reason string `json:"reason,omitempty"`
Source string `json:"source,omitempty"`
Until time.Time `json:"until,omitempty"`
TTLSecond int64 `json:"ttl_seconds,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
const maxOpsAdminJSONBytes = 1 << 20
func (s *Service) registerOpsRoutes(mux *http.ServeMux, prefix string) {
if mux == nil {
return
}
opsPrefix := prefix + "/ops"
dangerous := func(next http.HandlerFunc) http.HandlerFunc {
return s.adminDangerous("", next)
}
mux.HandleFunc(opsPrefix, s.admin(s.handleOpsIndex))
mux.HandleFunc(opsPrefix+"/players", s.admin(s.handleOpsPlayers))
mux.HandleFunc(opsPrefix+"/profile", s.admin(s.handleOpsProfile))
mux.HandleFunc(opsPrefix+"/profile/repair", dangerous(s.handleOpsRepair))
mux.HandleFunc(opsPrefix+"/events/replay", dangerous(s.handleOpsEventReplay))
mux.HandleFunc(opsPrefix+"/leaderboard", s.admin(s.handleOpsLeaderboard))
mux.HandleFunc(opsPrefix+"/leaderboard/repair", dangerous(s.handleOpsRankRepair))
mux.HandleFunc(opsPrefix+"/bans", dangerous(s.handleOpsBans))
}
func (s *Service) handleOpsIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"players": s.adminPrefix + "/ops/players?account_id=",
"profile": s.adminPrefix + "/ops/profile?account_id=",
"profile_fix": s.adminPrefix + "/ops/profile/repair",
"event_replay": s.adminPrefix + "/ops/events/replay?id=",
"leaderboard": s.adminPrefix + "/ops/leaderboard",
"bans": s.adminPrefix + "/ops/bans",
})
}
func (s *Service) handleOpsPlayers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
accountID := strings.TrimSpace(r.URL.Query().Get("account_id"))
if accountID == "" {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"accounts": s.accountSnapshots(),
"players": s.playerSnapshots(),
})
return
}
profile, active, found, err := s.lookupProfile(r, accountID)
if err != nil {
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
if !found {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player not found"})
return
}
response := map[string]any{
"account_id": accountID,
"profile": profile,
"active": active,
"account": s.accountSnapshot(accountID),
}
if s.players != nil {
if summary, ok, err := s.players.GetSummary(r.Context(), accountID); err == nil && ok {
response["summary"] = summary
}
}
if s.sanctions != nil {
if sanctions, err := s.sanctions.Active(r.Context(), moderation.SanctionQuery{Subject: accountID, Scope: "global"}); err == nil {
response["active_sanctions"] = sanctions
}
}
httpx.WriteJSON(w, http.StatusOK, response)
}
func (s *Service) handleOpsProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
accountID := strings.TrimSpace(r.URL.Query().Get("account_id"))
if accountID == "" {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "account_id is required"})
return
}
profile, active, found, err := s.lookupProfile(r, accountID)
if err != nil {
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
if !found {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "profile not found"})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"profile": profile,
"active": active,
})
}
func (s *Service) handleOpsRepair(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if s.players == nil {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player module not configured"})
return
}
var repair player.ProfileRepair
if err := decodeJSONOrQuery(w, r, &repair); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if repair.AccountID == "" {
repair.AccountID = strings.TrimSpace(r.URL.Query().Get("account_id"))
}
applyRepairQuery(r, &repair)
receipt, duplicate, submitted, err := s.submitOpsCmd(r, "logic.profile.repair", repair.AccountID, repair.Reason, repair)
if writeOpsCmdErr(w, err) {
return
}
if duplicate {
writeOpsDup(w, receipt)
return
}
profile, ok, err := s.players.RepairProfile(r.Context(), repair)
if err != nil {
if submitted {
s.markOpsCmdFail(r.Context(), receipt, err)
}
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
if !ok {
if submitted {
s.markOpsCmdFail(r.Context(), receipt, errors.New("profile not found"))
}
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "profile not found"})
return
}
if submitted {
receipt = s.markOpsAdminOK(r.Context(), receipt)
}
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{
"status": "repaired",
"profile": profile,
}, receipt)
}
func (s *Service) handleOpsEventReplay(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if s.eventLog == nil {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "eventlog is not configured"})
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
var body struct {
ID string `json:"id"`
}
if err := decodeJSONOrQuery(w, r, &body); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
id = strings.TrimSpace(body.ID)
}
if id == "" {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id is required"})
return
}
receipt, duplicate, submitted, submitErr := s.submitOpsCmd(r, "logic.event.replay", id, "event replay", map[string]any{"id": id})
if writeOpsCmdErr(w, submitErr) {
return
}
if duplicate {
writeOpsDup(w, receipt)
return
}
event, err := s.eventLog.RequeueDeadLetter(r.Context(), id, eventlog.RequeueOptions{})
if err != nil {
if submitted {
s.markOpsCmdFail(r.Context(), receipt, err)
}
status := http.StatusServiceUnavailable
if errors.Is(err, eventlog.ErrEventNotFound) {
status = http.StatusNotFound
}
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
return
}
if submitted {
receipt = s.markOpsAdminOK(r.Context(), receipt)
}
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{
"status": "requeued",
"event": event,
}, receipt)
}
func (s *Service) handleOpsLeaderboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if s.leaderboards == nil {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "leaderboard runtime not configured"})
return
}
boardID := strings.TrimSpace(r.URL.Query().Get("leaderboard_id"))
ownerID := strings.TrimSpace(r.URL.Query().Get("owner_id"))
if boardID == "" {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"snapshot": s.leaderboards.Snapshot(),
"definitions": s.leaderboards.Definitions(),
})
return
}
response := map[string]any{"leaderboard_id": boardID}
if definition, ok := s.leaderboards.Definition(boardID); ok {
response["definition"] = definition
} else {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "leaderboard not found"})
return
}
if ownerID != "" {
if record, ok := s.leaderboards.Record(boardID, ownerID); ok {
response["record"] = record
} else {
response["record_missing"] = true
}
}
limit := queryInt(r, "limit", 50)
records, err := s.leaderboards.List(r.Context(), boardID, leaderboard.ListOptions{Limit: limit})
if err != nil {
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
response["records"] = records
httpx.WriteJSON(w, http.StatusOK, response)
}
func (s *Service) handleOpsRankRepair(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if s.leaderboards == nil {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "leaderboard runtime not configured"})
return
}
var req lbRepairReq
if err := decodeJSONOrQuery(w, r, &req); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
fillRankRepairQuery(r, &req)
reason := "leaderboard repair"
if action := strings.TrimSpace(req.Action); action != "" {
reason = "leaderboard " + action
}
target := strings.TrimSpace(req.LeaderboardID)
if req.OwnerID != "" {
target += ":" + strings.TrimSpace(req.OwnerID)
}
receipt, duplicate, submitted, submitErr := s.submitOpsCmd(r, "logic.leaderboard.repair", target, reason, req)
if writeOpsCmdErr(w, submitErr) {
return
}
if duplicate {
writeOpsDup(w, receipt)
return
}
result, err := s.applyLbRepair(r, req)
if err != nil {
if submitted {
s.markOpsCmdFail(r.Context(), receipt, err)
}
status := http.StatusServiceUnavailable
if errors.Is(err, leaderboard.ErrLeaderboardNotFound) || errors.Is(err, leaderboard.ErrRecordNotFound) {
status = http.StatusNotFound
}
if errors.Is(err, leaderboard.ErrInvalidDefinition) || errors.Is(err, leaderboard.ErrInvalidSubmission) || errors.Is(err, leaderboard.ErrLeaderboardExists) {
status = http.StatusBadRequest
}
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
return
}
if submitted {
receipt = s.markOpsAdminOK(r.Context(), receipt)
}
writeOpsAdminReceipt(w, http.StatusOK, result, receipt)
}
func (s *Service) handleOpsBans(w http.ResponseWriter, r *http.Request) {
if s.sanctions == nil {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "moderation sanction store not configured"})
return
}
switch r.Method {
case http.MethodGet, http.MethodHead:
snapshot, err := s.sanctions.Snapshot(r.Context())
if err != nil {
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, snapshot)
case http.MethodPost:
var req sanctionRequest
if err := decodeJSONOrQuery(w, r, &req); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
fillSanctionQuery(r, &req)
receipt, duplicate, submitted, submitErr := s.submitOpsCmd(r, "logic.sanction.upsert", req.Subject, req.Reason, req)
if writeOpsCmdErr(w, submitErr) {
return
}
if duplicate {
writeOpsDup(w, receipt)
return
}
item, err := s.sanctions.Upsert(r.Context(), req.toSanction())
if err != nil {
if submitted {
s.markOpsCmdFail(r.Context(), receipt, err)
}
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if submitted {
receipt = s.markOpsAdminOK(r.Context(), receipt)
}
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{"status": "upserted", "sanction": item}, receipt)
case http.MethodDelete:
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id is required"})
return
}
reason := strings.TrimSpace(r.URL.Query().Get("reason"))
if reason == "" {
reason = "remove sanction"
}
receipt, duplicate, submitted, submitErr := s.submitOpsCmd(r, "logic.sanction.remove", id, reason, map[string]any{"id": id, "reason": reason})
if writeOpsCmdErr(w, submitErr) {
return
}
if duplicate {
writeOpsDup(w, receipt)
return
}
if err := s.sanctions.Remove(r.Context(), id); err != nil {
if submitted {
s.markOpsCmdFail(r.Context(), receipt, err)
}
status := http.StatusServiceUnavailable
if errors.Is(err, moderation.ErrSanctionMissing) {
status = http.StatusNotFound
}
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
return
}
if submitted {
receipt = s.markOpsAdminOK(r.Context(), receipt)
}
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{"status": "removed", "id": id}, receipt)
default:
w.Header().Set("Allow", "GET, POST, DELETE")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}
func (s *Service) lookupProfile(r *http.Request, accountID string) (player.Profile, bool, bool, error) {
if s.players == nil {
return player.Profile{}, false, false, nil
}
if profile, ok := s.players.Get(accountID); ok {
return profile, true, true, nil
}
profile, ok, err := s.players.LoadReadonly(r.Context(), accountID)
return profile, false, ok, err
}
func (s *Service) accountSnapshots() []AccountSnapshot {
if s.accounts == nil {
return nil
}
return s.accounts.Snapshot()
}
func (s *Service) playerSnapshots() []player.Record {
if s.players == nil {
return nil
}
return s.players.Snapshot()
}
func (s *Service) accountSnapshot(accountID string) *AccountSnapshot {
if s.accounts == nil {
return nil
}
for _, item := range s.accounts.Snapshot() {
if item.AccountID == accountID {
copy := item
return &copy
}
}
return nil
}
func decodeJSONOrQuery(w http.ResponseWriter, r *http.Request, dst any) error {
if r.Body == nil || r.ContentLength == 0 {
return nil
}
return httpx.DecodeStrictJSON(w, r, maxOpsAdminJSONBytes, dst)
}
func applyRepairQuery(r *http.Request, repair *player.ProfileRepair) {
query := r.URL.Query()
if repair.Reason == "" {
repair.Reason = strings.TrimSpace(query.Get("reason"))
}
if repair.State == nil {
if raw := strings.TrimSpace(query.Get("state")); raw != "" {
repair.State = &raw
}
}
if repair.Name == nil {
if raw := strings.TrimSpace(query.Get("name")); raw != "" {
repair.Name = &raw
}
}
if repair.Level == nil {
if raw := strings.TrimSpace(query.Get("level")); raw != "" {
if value, err := strconv.Atoi(raw); err == nil {
repair.Level = &value
}
}
}
}
func fillRankRepairQuery(r *http.Request, req *lbRepairReq) {
query := r.URL.Query()
if req.Action == "" {
req.Action = strings.TrimSpace(query.Get("action"))
}
if req.LeaderboardID == "" {
req.LeaderboardID = strings.TrimSpace(query.Get("leaderboard_id"))
}
if req.OwnerID == "" {
req.OwnerID = strings.TrimSpace(query.Get("owner_id"))
}
if req.Title == "" {
req.Title = strings.TrimSpace(query.Get("title"))
}
if req.SortOrder == "" {
req.SortOrder = strings.TrimSpace(query.Get("sort_order"))
}
if req.Operator == "" {
req.Operator = strings.TrimSpace(query.Get("operator"))
}
if raw := strings.TrimSpace(query.Get("score")); raw != "" && req.Score == 0 {
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
req.Score = value
}
}
if raw := strings.TrimSpace(query.Get("subscore")); raw != "" && req.Subscore == 0 {
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
req.Subscore = value
}
}
if raw := strings.TrimSpace(query.Get("max_size")); raw != "" && req.MaxSize == 0 {
if value, err := strconv.Atoi(raw); err == nil {
req.MaxSize = value
}
}
}
func fillSanctionQuery(r *http.Request, req *sanctionRequest) {
query := r.URL.Query()
if req.ID == "" {
req.ID = strings.TrimSpace(query.Get("id"))
}
if req.Subject == "" {
req.Subject = strings.TrimSpace(query.Get("subject"))
}
if req.Scope == "" {
req.Scope = strings.TrimSpace(query.Get("scope"))
}
if req.Kind == "" {
req.Kind = strings.TrimSpace(query.Get("kind"))
}
if req.Reason == "" {
req.Reason = strings.TrimSpace(query.Get("reason"))
}
if req.Source == "" {
req.Source = strings.TrimSpace(query.Get("source"))
}
if raw := strings.TrimSpace(query.Get("ttl_seconds")); raw != "" && req.TTLSecond == 0 {
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
req.TTLSecond = value
}
}
}
func (req sanctionRequest) toSanction() moderation.Sanction {
kind := moderation.SanctionKind(strings.TrimSpace(req.Kind))
if kind == "" {
kind = moderation.SanctionBan
}
scope := strings.TrimSpace(req.Scope)
if scope == "" {
scope = "global"
}
until := req.Until
if until.IsZero() && req.TTLSecond > 0 {
until = time.Now().UTC().Add(time.Duration(req.TTLSecond) * time.Second)
}
source := strings.TrimSpace(req.Source)
if source == "" {
source = "admin"
}
return moderation.Sanction{
ID: req.ID,
Subject: req.Subject,
Scope: scope,
Kind: kind,
Reason: req.Reason,
Source: source,
Until: until,
Meta: req.Meta,
}
}
func (s *Service) applyLbRepair(r *http.Request, req lbRepairReq) (map[string]any, error) {
action := strings.TrimSpace(req.Action)
if action == "" {
action = "submit"
}
switch action {
case "create":
definition, err := s.leaderboards.Create(r.Context(), leaderboard.Definition{
ID: req.LeaderboardID,
Title: req.Title,
SortOrder: req.SortOrder,
Operator: req.Operator,
MaxSize: req.MaxSize,
Metadata: req.Metadata,
})
return map[string]any{"status": "created", "definition": definition}, err
case "submit", "set_score":
record, err := s.leaderboards.Submit(r.Context(), req.LeaderboardID, leaderboard.Submission{
OwnerID: req.OwnerID,
Score: req.Score,
Subscore: req.Subscore,
Metadata: req.Metadata,
})
return map[string]any{"status": "updated", "record": record}, err
case "delete_record":
record, err := s.leaderboards.DeleteRecord(r.Context(), req.LeaderboardID, req.OwnerID)
return map[string]any{"status": "record_deleted", "record": record}, err
case "reset":
definition, err := s.leaderboards.Reset(r.Context(), req.LeaderboardID)
return map[string]any{"status": "reset", "definition": definition}, err
case "delete_leaderboard":
definition, err := s.leaderboards.Delete(r.Context(), req.LeaderboardID)
return map[string]any{"status": "deleted", "definition": definition}, err
default:
return nil, leaderboard.ErrInvalidDefinition
}
}
func queryInt(r *http.Request, key string, fallback int) int {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return value
}