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

+422
View File
@@ -0,0 +1,422 @@
package httpx
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"longheng.io/server/internal/platform/admincmd"
"longheng.io/server/internal/platform/audit"
)
const AdminTokenHeader = "X-Admin-Token"
const AdminActorHeader = "X-Admin-Actor"
const AdminConfirmHeader = "X-Admin-Confirm"
const AdminTraceHeader = "X-Admin-Trace-ID"
const AdminIdempotencyHeader = "X-Admin-Idempotency-Key"
const AdminOperationHeader = "X-Admin-Operation-ID"
const AdminReceiptHeader = "X-Admin-Receipt-ID"
type AdminToken struct {
Name string
Token string
Role string
Scopes []string
}
type AdminPolicy struct {
Token string
RBACEnabled bool
RequireConfirmation bool
Tokens []AdminToken
Audit *audit.Logger
}
type adminPrincipal struct {
Name string
Role string
Scopes []string
Root bool
}
type adminPrincipalKey struct{}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func WrapAdmin(token string, logger *slog.Logger, scope string, next http.HandlerFunc) http.HandlerFunc {
return wrapAdminPolicy(func() AdminPolicy {
return AdminPolicy{Token: token}
}, logger, scope, "", false, next)
}
func WrapAdminPolicy(policyProvider func() AdminPolicy, logger *slog.Logger, scope string, next http.HandlerFunc) http.HandlerFunc {
return wrapAdminPolicy(policyProvider, logger, scope, "", false, next)
}
func WrapDangerousAdminPolicy(policyProvider func() AdminPolicy, logger *slog.Logger, scope, operation string, next http.HandlerFunc) http.HandlerFunc {
return wrapAdminPolicy(policyProvider, logger, scope, operation, true, next)
}
func wrapAdminPolicy(policyProvider func() AdminPolicy, logger *slog.Logger, scope, operation string, dangerous bool, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
traceID := adminTraceID(r)
w.Header().Set(AdminTraceHeader, traceID)
writer := &statusWriter{
ResponseWriter: w,
status: http.StatusOK,
}
policy := AdminPolicy{}
if policyProvider != nil {
policy = policyProvider()
}
principal := adminPrincipal{Name: "unauthenticated", Role: "none"}
requiredScope := adminRequiredScope(scope, r.Method)
operationID := strings.TrimSpace(operation)
if operationID == "" {
operationID = AdminOperationID(r.Method, r.URL.Path)
}
w.Header().Set(AdminOperationHeader, operationID)
receipt := admincmd.Receipt{}
defer func() {
if rec := recover(); rec != nil {
if logger != nil {
logger.Error("admin handler panic recovered", "scope", scope, "path", r.URL.Path, "trace_id", traceID, "panic", rec)
}
writer.status = http.StatusInternalServerError
WriteJSON(writer, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
recordAdminAudit(r, policy, principal, scope, requiredScope, operationID, receipt, traceID, writer.status, time.Since(start))
if logger != nil {
logger.Info(
"admin request",
"scope", scope,
"actor", principal.Name,
"role", principal.Role,
"method", r.Method,
"path", r.URL.Path,
"status", writer.status,
"trace_id", traceID,
"duration_ms", fmt.Sprintf("%.3f", float64(time.Since(start).Microseconds())/1000.0),
)
}
}()
if !adminPolicyOK(policy) {
writer.status = http.StatusServiceUnavailable
WriteJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "admin token not configured"})
return
}
var ok bool
principal, ok = authenticateAdmin(policy, r.Header.Get(AdminTokenHeader), r.Header.Get(AdminActorHeader))
if !ok {
writer.status = http.StatusUnauthorized
WriteJSON(writer, http.StatusUnauthorized, map[string]string{"error": "invalid admin token"})
return
}
if policy.RBACEnabled && !principalAllows(principal, scope, requiredScope) {
writer.status = http.StatusForbidden
WriteJSON(writer, http.StatusForbidden, map[string]string{
"error": "admin scope forbidden",
"required_scope": requiredScope,
})
return
}
if policy.RequireConfirmation && adminMethodMutates(r.Method) {
if r.Header.Get(AdminConfirmHeader) != operationID {
writer.status = http.StatusPreconditionRequired
WriteJSON(writer, http.StatusPreconditionRequired, map[string]string{
"error": "dangerous admin operation requires confirmation",
"header": AdminConfirmHeader,
"operation_id": operationID,
})
return
}
}
if dangerous && adminMethodMutates(r.Method) && strings.TrimSpace(r.Header.Get(AdminIdempotencyHeader)) == "" {
writer.status = http.StatusPreconditionRequired
WriteJSON(writer, http.StatusPreconditionRequired, map[string]string{
"error": "dangerous admin operation requires idempotency key",
"header": AdminIdempotencyHeader,
"operation_id": operationID,
})
return
}
if adminMethodMutates(r.Method) {
receipt = adminCommandReceipt(r, principal, scope, operationID, start)
if receipt.ID != "" {
w.Header().Set(AdminReceiptHeader, receipt.ID)
}
}
r = r.WithContext(context.WithValue(r.Context(), adminPrincipalKey{}, principal.Name))
next(writer, r)
}
}
func adminPolicyOK(policy AdminPolicy) bool {
if strings.TrimSpace(policy.Token) != "" {
return true
}
for _, token := range policy.Tokens {
if strings.TrimSpace(token.Token) != "" {
return true
}
}
return false
}
func authenticateAdmin(policy AdminPolicy, headerToken, actorHeader string) (adminPrincipal, bool) {
headerToken = strings.TrimSpace(headerToken)
if headerToken == "" {
return adminPrincipal{}, false
}
if !policy.RBACEnabled && policy.Token != "" && subtle.ConstantTimeCompare([]byte(headerToken), []byte(policy.Token)) == 1 {
name := strings.TrimSpace(actorHeader)
if name == "" {
name = "root"
}
return adminPrincipal{Name: name, Role: "admin", Scopes: []string{"*"}, Root: true}, true
}
if !policy.RBACEnabled {
return adminPrincipal{}, false
}
for _, token := range policy.Tokens {
if token.Token == "" || subtle.ConstantTimeCompare([]byte(headerToken), []byte(token.Token)) != 1 {
continue
}
// RBAC token 的名称就是已认证身份,不能再由请求头覆盖,否则审计和
// 持久化管理命令的 actor 都可被 token 持有者任意伪造。
name := strings.TrimSpace(token.Name)
role := strings.ToLower(strings.TrimSpace(token.Role))
if role == "" {
role = "operator"
}
return adminPrincipal{
Name: name,
Role: role,
Scopes: append([]string(nil), token.Scopes...),
}, true
}
return adminPrincipal{}, false
}
// AuthenticatedAdminActor 返回管理鉴权绑定的 actor。经过 WrapAdminPolicy 的
// RBAC 请求始终取 token.Name;未经过 wrapper 的内部测试和非 RBAC 兼容入口才回退请求头。
func AuthenticatedAdminActor(r *http.Request) string {
if r == nil {
return ""
}
if actor, ok := r.Context().Value(adminPrincipalKey{}).(string); ok {
return strings.TrimSpace(actor)
}
return strings.TrimSpace(r.Header.Get(AdminActorHeader))
}
func principalAllows(principal adminPrincipal, scope, requiredScope string) bool {
if principal.Root || principal.Role == "admin" {
return true
}
if len(principal.Scopes) > 0 {
return scopesAllow(principal.Scopes, scope, requiredScope)
}
switch principal.Role {
case "operator":
return true
case "viewer", "auditor":
return strings.HasSuffix(requiredScope, ":read")
default:
return false
}
}
func scopesAllow(scopes []string, scope, requiredScope string) bool {
scope = strings.ToLower(strings.TrimSpace(scope))
requiredScope = strings.ToLower(strings.TrimSpace(requiredScope))
for _, item := range scopes {
item = strings.ToLower(strings.TrimSpace(item))
switch item {
case "*", requiredScope, scope + ":*":
return true
}
if strings.HasSuffix(requiredScope, ":read") && item == "*:read" {
return true
}
if !strings.HasSuffix(requiredScope, ":read") && item == "*:write" {
return true
}
}
return false
}
func adminRequiredScope(scope, method string) string {
scope = strings.Trim(strings.ToLower(strings.TrimSpace(scope)), ":")
if scope == "" {
scope = "admin"
}
if adminMethodMutates(method) {
return scope + ":write"
}
return scope + ":read"
}
func adminMethodMutates(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func AdminOperationID(method, path string) string {
method = strings.ToUpper(strings.TrimSpace(method))
path = strings.TrimSpace(path)
if method == "" {
method = "GET"
}
if path == "" {
path = "/"
}
return method + " " + path
}
func adminTraceID(r *http.Request) string {
if r != nil {
if traceID := strings.TrimSpace(r.Header.Get(AdminTraceHeader)); traceID != "" {
return traceID
}
if requestID := strings.TrimSpace(r.Header.Get("X-Request-ID")); requestID != "" {
return requestID
}
}
var raw [12]byte
if _, err := rand.Read(raw[:]); err != nil {
return fmt.Sprintf("admin-%d", time.Now().UTC().UnixNano())
}
return "admin-" + hex.EncodeToString(raw[:])
}
func adminCommandReceipt(r *http.Request, principal adminPrincipal, scope, operationID string, now time.Time) admincmd.Receipt {
if r == nil {
return admincmd.Receipt{}
}
receipt, err := admincmd.NewReceipt(admincmd.Command{
Operation: operationID,
Scope: scope,
Target: r.URL.Path,
Actor: principal.Name,
IdempotencyKey: r.Header.Get(AdminIdempotencyHeader),
Confirmation: r.Header.Get(AdminConfirmHeader),
Params: map[string]any{
"method": r.Method,
"query": redactedAdminQuery(r.URL.RawQuery),
},
}, "accepted", now)
if err != nil {
return admincmd.Receipt{}
}
return receipt
}
func recordAdminAudit(r *http.Request, policy AdminPolicy, principal adminPrincipal, scope, requiredScope, operationID string, receipt admincmd.Receipt, traceID string, status int, duration time.Duration) {
if policy.Audit == nil {
return
}
fields := map[string]string{
"method": r.Method,
"path": r.URL.Path,
"scope": scope,
"required_scope": requiredScope,
"operation_id": operationID,
"status": fmt.Sprint(status),
"duration_ms": fmt.Sprintf("%.3f", float64(duration.Microseconds())/1000.0),
"role": principal.Role,
}
if receipt.ID != "" {
fields["receipt_id"] = receipt.ID
fields["receipt_status"] = receipt.Status
}
if receipt.ParamsHash != "" {
fields["params_hash"] = receipt.ParamsHash
}
if receipt.IdempotencyKey != "" {
fields["idempotency_key"] = receipt.IdempotencyKey
}
if r.URL.RawQuery != "" {
fields["query"] = redactedAdminQuery(r.URL.RawQuery)
}
_ = policy.Audit.Record(r.Context(), audit.Event{
Actor: principal.Name,
Action: "admin.request",
Target: r.URL.Path,
TraceID: traceID,
Fields: fields,
})
}
// redactedAdminQuery 在查询参数进入审计、回执或日志前统一清除凭证值。
// 解析失败时宁可丢失原始查询,也不能把未知格式的密钥原样落盘。
func redactedAdminQuery(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
values, err := url.ParseQuery(raw)
if err != nil {
return "[redacted-invalid-query]"
}
for key, items := range values {
if !secretQueryKey(key) {
continue
}
for idx := range items {
items[idx] = "[redacted]"
}
values[key] = items
}
return values.Encode()
}
func secretQueryKey(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
if key == "" {
return false
}
switch key {
case "token", "password", "passwd", "authorization", "api_key", "apikey", "credential", "credentials":
return true
}
return strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "_password") ||
strings.Contains(key, "secret") ||
strings.Contains(key, "credential")
}
func WriteJSON(w http.ResponseWriter, code int, payload any) {
data, err := json.Marshal(payload)
if err != nil {
code = http.StatusInternalServerError
data = []byte(`{"error":"json encode failed"}`)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write(append(data, '\n'))
}
+187
View File
@@ -0,0 +1,187 @@
package httpx
import (
"context"
"net"
"net/http"
"net/netip"
"strings"
)
var DefaultClientIPHeaders = []string{"X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP"}
type ClientIPOptions struct {
Headers []string
TrustedProxies []string
}
type IPInfo struct {
IP string `json:"ip"`
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
ASN string `json:"asn,omitempty"`
Source string `json:"source,omitempty"`
}
type IPLocator interface {
LookupIP(context.Context, string) (IPInfo, bool, error)
}
type StaticIPRange struct {
CIDR string `json:"cidr"`
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
ASN string `json:"asn,omitempty"`
Source string `json:"source,omitempty"`
}
type StaticIPLocator struct {
ranges []staticIPRange
}
type staticIPRange struct {
prefix netip.Prefix
info IPInfo
}
func NewStaticIPLocator(ranges []StaticIPRange) (*StaticIPLocator, error) {
out := make([]staticIPRange, 0, len(ranges))
for _, r := range ranges {
cidr := strings.TrimSpace(r.CIDR)
if cidr == "" {
continue
}
prefix, err := netip.ParsePrefix(cidr)
if err != nil {
return nil, err
}
out = append(out, staticIPRange{
prefix: prefix,
info: IPInfo{
Country: strings.TrimSpace(r.Country),
Region: strings.TrimSpace(r.Region),
City: strings.TrimSpace(r.City),
ASN: strings.TrimSpace(r.ASN),
Source: strings.TrimSpace(r.Source),
},
})
}
return &StaticIPLocator{ranges: out}, nil
}
func (l *StaticIPLocator) LookupIP(ctx context.Context, ip string) (IPInfo, bool, error) {
if err := ctxErr(ctx); err != nil {
return IPInfo{}, false, err
}
addr, ok := parseIPAddr(ip)
if !ok || l == nil {
return IPInfo{}, false, nil
}
for _, r := range l.ranges {
if !r.prefix.Contains(addr) {
continue
}
info := r.info
info.IP = addr.String()
return info, true, nil
}
return IPInfo{}, false, nil
}
func ClientIP(r *http.Request, options ClientIPOptions) string {
if r == nil {
return ""
}
remote := RemoteAddrIP(r.RemoteAddr)
if remote == "" {
return ""
}
if !remoteTrusted(remote, options.TrustedProxies) {
return remote
}
for _, header := range clientIPHeaders(options.Headers) {
for _, candidate := range strings.Split(r.Header.Get(header), ",") {
if ip := normalizeIP(candidate); ip != "" {
return ip
}
}
}
return remote
}
func RemoteAddrIP(remoteAddr string) string {
remoteAddr = strings.TrimSpace(remoteAddr)
if remoteAddr == "" {
return ""
}
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return normalizeIP(host)
}
return normalizeIP(remoteAddr)
}
func clientIPHeaders(headers []string) []string {
if len(headers) == 0 {
return DefaultClientIPHeaders
}
out := make([]string, 0, len(headers))
for _, header := range headers {
header = strings.TrimSpace(header)
if header != "" {
out = append(out, header)
}
}
if len(out) == 0 {
return DefaultClientIPHeaders
}
return out
}
func remoteTrusted(remote string, cidrs []string) bool {
if len(cidrs) == 0 {
return false
}
addr, ok := parseIPAddr(remote)
if !ok {
return false
}
for _, cidr := range cidrs {
prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr))
if err != nil {
continue
}
if prefix.Contains(addr) {
return true
}
}
return false
}
func normalizeIP(value string) string {
addr, ok := parseIPAddr(value)
if !ok {
return ""
}
return addr.String()
}
func parseIPAddr(value string) (netip.Addr, bool) {
value = strings.Trim(strings.TrimSpace(value), "[]")
if value == "" {
return netip.Addr{}, false
}
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}
@@ -0,0 +1,61 @@
package httpx
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)
// DecodeStrictJSON 读取单个 JSON 请求体,统一限制大小、拒绝未知字段并检查尾部多余 JSON。
func DecodeStrictJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, out any) error {
if r == nil || r.Body == nil {
return errors.New("request body is required")
}
body := http.MaxBytesReader(w, r.Body, maxBytes)
defer func() {
_ = body.Close()
}()
decoder := json.NewDecoder(body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
return err
}
var extra json.RawMessage
if err := decoder.Decode(&extra); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("request body must contain a single JSON object: %w", err)
}
return errors.New("request body must contain a single JSON object")
}
// DecodeStrictJSONReader 从任意 reader 读取单个 JSON 对象;适合已经自行处理空 body 的 handler helper。
func DecodeStrictJSONReader(reader io.Reader, maxBytes int64, out any) error {
if reader == nil {
return errors.New("request body is required")
}
data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
if err != nil {
return err
}
if int64(len(data)) > maxBytes {
return fmt.Errorf("request body exceeds %d bytes", maxBytes)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
return err
}
var extra json.RawMessage
if err := decoder.Decode(&extra); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("request body must contain a single JSON object: %w", err)
}
return errors.New("request body must contain a single JSON object")
}
+128
View File
@@ -0,0 +1,128 @@
package httpx
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"sync"
"time"
)
type Server struct {
name string
addr string
logger *slog.Logger
mux *http.ServeMux
opts Timeouts
mu sync.Mutex
listener net.Listener
server *http.Server
}
type Timeouts struct {
ReadTimeout time.Duration
ReadHeaderTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
}
func New(name, addr string, logger *slog.Logger, opts Timeouts) *Server {
if addr == "" {
addr = ":18080"
}
return &Server{
name: name,
addr: addr,
logger: logger,
mux: http.NewServeMux(),
opts: normalizeTimeouts(opts),
}
}
func (s *Server) Name() string {
return s.name
}
func (s *Server) Mux() *http.ServeMux {
return s.mux
}
func (s *Server) Start(_ context.Context) error {
s.mu.Lock()
if s.listener != nil {
s.mu.Unlock()
return nil
}
s.mu.Unlock()
ln, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
s.mu.Lock()
s.listener = ln
s.server = NewHTTPServer(s.mux, s.opts)
server := s.server
s.mu.Unlock()
go func() {
err := server.Serve(ln)
if err != nil && !errors.Is(err, http.ErrServerClosed) && s.logger != nil {
s.logger.Error("admin http serve failed", "error", err)
}
}()
if s.logger != nil {
s.logger.Info("admin http listening", "addr", ln.Addr().String())
}
return nil
}
func NewHTTPServer(handler http.Handler, opts Timeouts) *http.Server {
opts = normalizeTimeouts(opts)
return &http.Server{
Handler: handler,
ReadTimeout: opts.ReadTimeout,
ReadHeaderTimeout: opts.ReadHeaderTimeout,
WriteTimeout: opts.WriteTimeout,
IdleTimeout: opts.IdleTimeout,
}
}
func normalizeTimeouts(opts Timeouts) Timeouts {
if opts.ReadTimeout <= 0 {
opts.ReadTimeout = 10 * time.Second
}
if opts.ReadHeaderTimeout <= 0 {
opts.ReadHeaderTimeout = 5 * time.Second
}
if opts.WriteTimeout <= 0 {
opts.WriteTimeout = 15 * time.Second
}
if opts.IdleTimeout <= 0 {
opts.IdleTimeout = 60 * time.Second
}
return opts
}
func (s *Server) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
server := s.server
s.server = nil
s.listener = nil
s.mu.Unlock()
if server == nil {
return nil
}
if s.logger != nil {
s.logger.Info("stopping admin http", "addr", s.addr)
}
return server.Shutdown(ctx)
}