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

@@ -0,0 +1,126 @@
package discovery
import (
"context"
"strings"
"time"
"longheng.io/server/internal/platform/registry"
)
type NodeProbe func(context.Context, registry.Node) error
type ProbeOptions struct {
Service string
Timeout time.Duration
Probe NodeProbe
OnChange func(ProbeResult)
}
type ProbeResult struct {
Service string `json:"service"`
NodeID string `json:"node_id"`
Healthy bool `json:"healthy"`
Error string `json:"error,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
func (r *Resolver) MustAlive(ctx context.Context, service, nodeID string) (bool, error) {
node, ok, err := r.lookupNodeDirect(ctx, service, nodeID)
if err != nil || !ok {
return false, err
}
if r.rejectsMaintaining(node) {
return false, nil
}
return nodeAccepting(node), nil
}
func (r *Resolver) ProbeNodes(ctx context.Context, options ProbeOptions) ([]ProbeResult, error) {
if r == nil || r.reg == nil {
return nil, ErrNoRegistry
}
if ctx == nil {
ctx = context.Background()
}
service := strings.TrimSpace(options.Service)
if service == "" {
return nil, ErrNoNodes
}
if options.Probe == nil {
return nil, ErrNoProbe
}
nodes, err := r.reg.List(ctx, service)
if err != nil {
return nil, err
}
now := r.now().UTC()
results := make([]ProbeResult, 0, len(nodes))
for _, node := range nodes {
if strings.TrimSpace(node.NodeID) == "" {
continue
}
result := ProbeResult{
Service: service,
NodeID: node.NodeID,
CheckedAt: now,
}
probeCtx := ctx
cancel := func() {}
if options.Timeout > 0 {
probeCtx, cancel = context.WithTimeout(ctx, options.Timeout)
}
err := options.Probe(probeCtx, node)
cancel()
if err != nil {
result.Error = err.Error()
r.MarkFailure(node.NodeID)
} else {
result.Healthy = true
r.MarkSuccess(node.NodeID)
}
r.Invalidate(service)
if options.OnChange != nil {
options.OnChange(result)
}
results = append(results, result)
}
return results, nil
}
func (r *Resolver) lookupNodeDirect(ctx context.Context, service, nodeID string) (registry.Node, bool, error) {
if r == nil || r.reg == nil {
return registry.Node{}, false, ErrNoRegistry
}
if ctx == nil {
ctx = context.Background()
}
service = strings.TrimSpace(service)
nodeID = strings.TrimSpace(nodeID)
if service == "" {
return registry.Node{}, false, ErrNoNodes
}
if nodeID == "" {
return registry.Node{}, false, nil
}
nodes, err := r.reg.List(ctx, service)
if err != nil {
return registry.Node{}, false, err
}
for _, node := range nodes {
if strings.TrimSpace(node.NodeID) == nodeID {
return node, true, nil
}
}
return registry.Node{}, false, nil
}
func (r *Resolver) rejectsMaintaining(node registry.Node) bool {
if r == nil {
return false
}
r.mu.Lock()
reject := r.rejectMaintaining
r.mu.Unlock()
return reject && nodeMaintaining(node.Meta)
}
@@ -0,0 +1,706 @@
package discovery
import (
"context"
"encoding/base64"
"errors"
"hash/fnv"
"math/rand"
"sort"
"strconv"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/registry"
)
var (
ErrNoRegistry = errors.New("discovery registry is required")
ErrNoNodes = errors.New("discovery service has no nodes")
ErrNoRoute = errors.New("discovery route has no nodes")
ErrNoProbe = errors.New("discovery health probe is required")
ErrRouteRequiresKey = errors.New("discovery route requires routing key")
)
type Strategy string
const (
StrategyHash Strategy = "hash"
StrategyRandom Strategy = "random"
StrategyRoundRobin Strategy = "round_robin"
StrategyWeightedRoundRobin Strategy = "weighted_round_robin"
)
type Resolver struct {
reg registry.Registry
cacheTTL time.Duration
failureTTL time.Duration
failureThreshold int
rejectMaintaining bool
strategy Strategy
clock func() time.Time
mu sync.Mutex
cache map[string]cacheEntry
failures map[string]failureState
cursors map[string]uint64
rng *rand.Rand
}
type cacheEntry struct {
nodes []registry.Node
expiresAt time.Time
}
type failureState struct {
count int
expiresAt time.Time
}
type Snapshot struct {
CacheTTLSeconds int64 `json:"cache_ttl_seconds"`
FailureTTLSeconds int64 `json:"failure_ttl_seconds"`
FailureThreshold int `json:"failure_threshold"`
RejectMaintaining bool `json:"reject_maintaining"`
Strategy Strategy `json:"strategy"`
CachedServices []CachedService `json:"cached_services"`
Failures []FailureSnapshot `json:"failures"`
}
type CachedService struct {
Service string `json:"service"`
NodeIDs []string `json:"node_ids"`
ExpiresAt time.Time `json:"expires_at"`
}
type FailureSnapshot struct {
NodeID string `json:"node_id"`
Count int `json:"count"`
ExpiresAt time.Time `json:"expires_at"`
Suppressed bool `json:"suppressed"`
}
func NewResolver(reg registry.Registry, cacheTTL, failureTTL time.Duration, rejectMaintaining bool) *Resolver {
if cacheTTL <= 0 {
cacheTTL = time.Second
}
if failureTTL <= 0 {
failureTTL = 5 * time.Second
}
return &Resolver{
reg: reg,
cacheTTL: cacheTTL,
failureTTL: failureTTL,
failureThreshold: 1,
rejectMaintaining: rejectMaintaining,
strategy: StrategyHash,
clock: time.Now,
cache: make(map[string]cacheEntry),
failures: make(map[string]failureState),
cursors: make(map[string]uint64),
rng: rand.New(rand.NewSource(time.Now().UnixNano())), // #nosec G404 -- 非安全用途:服务发现随机负载均衡,不用于凭证或加密。
}
}
func (r *Resolver) Resolve(ctx context.Context, service, key string) (registry.Node, bool, error) {
if r == nil || r.reg == nil {
return registry.Node{}, false, ErrNoRegistry
}
service = strings.TrimSpace(service)
if service == "" {
return registry.Node{}, false, ErrNoNodes
}
nodes, err := r.nodes(ctx, service)
if err != nil {
return registry.Node{}, false, err
}
candidates := r.candidates(nodes)
if len(candidates) == 0 {
return registry.Node{}, false, nil
}
return r.selectNode(service, candidates, key), true, nil
}
func (r *Resolver) ResolveWithTags(ctx context.Context, service, key string, tags []string, matchAny bool) (registry.Node, bool, error) {
if r == nil || r.reg == nil {
return registry.Node{}, false, ErrNoRegistry
}
service = strings.TrimSpace(service)
if service == "" {
return registry.Node{}, false, ErrNoNodes
}
nodes, err := r.nodes(ctx, service)
if err != nil {
return registry.Node{}, false, err
}
candidates := filterNodesByTags(r.candidates(nodes), tags, matchAny)
if len(candidates) == 0 {
return registry.Node{}, false, nil
}
return r.selectNode(service+":"+tagBucket(tags, matchAny), candidates, key), true, nil
}
func (r *Resolver) ResolveRoute(ctx context.Context, service, route, key string) (registry.Node, bool, error) {
if r == nil || r.reg == nil {
return registry.Node{}, false, ErrNoRegistry
}
service = strings.TrimSpace(service)
route = strings.TrimSpace(route)
if service == "" {
return registry.Node{}, false, ErrNoNodes
}
if route == "" {
return r.Resolve(ctx, service, key)
}
nodes, err := r.nodes(ctx, service)
if err != nil {
return registry.Node{}, false, err
}
candidates, err := r.routeCandidates(nodes, route, key)
if err != nil {
return registry.Node{}, false, err
}
if len(candidates) == 0 {
if nodesDeclareRoutes(nodes) {
return registry.Node{}, false, ErrNoRoute
}
return registry.Node{}, false, nil
}
return r.selectNode(service+":"+route, candidates, key), true, nil
}
func (r *Resolver) ResolveRouteWithTags(ctx context.Context, service, route, key string, tags []string, matchAny bool) (registry.Node, bool, error) {
if r == nil || r.reg == nil {
return registry.Node{}, false, ErrNoRegistry
}
service = strings.TrimSpace(service)
route = strings.TrimSpace(route)
if service == "" {
return registry.Node{}, false, ErrNoNodes
}
if route == "" {
return r.ResolveWithTags(ctx, service, key, tags, matchAny)
}
nodes, err := r.nodes(ctx, service)
if err != nil {
return registry.Node{}, false, err
}
candidates, err := r.routeCandidates(nodes, route, key)
if err != nil {
return registry.Node{}, false, err
}
candidates = filterNodesByTags(candidates, tags, matchAny)
if len(candidates) == 0 {
if nodesDeclareRoutes(nodes) {
return registry.Node{}, false, ErrNoRoute
}
return registry.Node{}, false, nil
}
return r.selectNode(service+":"+route+":"+tagBucket(tags, matchAny), candidates, key), true, nil
}
func (r *Resolver) MarkFailure(nodeID string) {
if r == nil {
return
}
nodeID = strings.TrimSpace(nodeID)
if nodeID == "" {
return
}
now := r.now()
r.mu.Lock()
r.pruneFailuresLocked(now)
state := r.failures[nodeID]
if state.expiresAt.IsZero() || !state.expiresAt.After(now) {
state.count = 0
}
state.count++
state.expiresAt = now.Add(r.failureTTL)
r.failures[nodeID] = state
r.mu.Unlock()
}
func (r *Resolver) MarkSuccess(nodeID string) {
if r == nil {
return
}
nodeID = strings.TrimSpace(nodeID)
if nodeID == "" {
return
}
r.mu.Lock()
delete(r.failures, nodeID)
r.mu.Unlock()
}
func (r *Resolver) Invalidate(service string) {
if r == nil {
return
}
service = strings.TrimSpace(service)
r.mu.Lock()
if service == "" {
r.cache = make(map[string]cacheEntry)
} else {
delete(r.cache, service)
}
r.mu.Unlock()
}
func (r *Resolver) SetFailureThreshold(threshold int) {
if r == nil {
return
}
if threshold <= 0 {
threshold = 1
}
r.mu.Lock()
r.failureThreshold = threshold
r.mu.Unlock()
}
func (r *Resolver) Configure(cacheTTL, failureTTL time.Duration, failureThreshold int, rejectMaintaining bool, strategy Strategy) {
if r == nil {
return
}
if cacheTTL <= 0 {
cacheTTL = time.Second
}
if failureTTL <= 0 {
failureTTL = 5 * time.Second
}
if failureThreshold <= 0 {
failureThreshold = 1
}
r.mu.Lock()
r.cacheTTL = cacheTTL
r.failureTTL = failureTTL
r.failureThreshold = failureThreshold
r.rejectMaintaining = rejectMaintaining
r.strategy = normalizeStrategy(strategy)
r.cache = make(map[string]cacheEntry)
r.cursors = make(map[string]uint64)
r.pruneFailuresLocked(r.now())
r.mu.Unlock()
}
func (r *Resolver) SetStrategy(strategy Strategy) {
if r == nil {
return
}
r.mu.Lock()
r.strategy = normalizeStrategy(strategy)
r.cursors = make(map[string]uint64)
r.mu.Unlock()
}
func (r *Resolver) Snapshot() Snapshot {
if r == nil {
return Snapshot{}
}
now := r.now()
r.mu.Lock()
r.pruneFailuresLocked(now)
cache := make([]CachedService, 0, len(r.cache))
for service, entry := range r.cache {
nodeIDs := make([]string, 0, len(entry.nodes))
for _, node := range entry.nodes {
nodeID := strings.TrimSpace(node.NodeID)
if nodeID != "" {
nodeIDs = append(nodeIDs, nodeID)
}
}
sort.Strings(nodeIDs)
cache = append(cache, CachedService{
Service: service,
NodeIDs: nodeIDs,
ExpiresAt: entry.expiresAt,
})
}
failures := make([]FailureSnapshot, 0, len(r.failures))
for nodeID, state := range r.failures {
failures = append(failures, FailureSnapshot{
NodeID: nodeID,
Count: state.count,
ExpiresAt: state.expiresAt,
Suppressed: state.count >= r.failureThreshold && state.expiresAt.After(now),
})
}
snapshot := Snapshot{
CacheTTLSeconds: int64(r.cacheTTL / time.Second),
FailureTTLSeconds: int64(r.failureTTL / time.Second),
FailureThreshold: r.failureThreshold,
RejectMaintaining: r.rejectMaintaining,
Strategy: r.strategy,
CachedServices: cache,
Failures: failures,
}
r.mu.Unlock()
sort.Slice(snapshot.CachedServices, func(i, j int) bool {
return snapshot.CachedServices[i].Service < snapshot.CachedServices[j].Service
})
sort.Slice(snapshot.Failures, func(i, j int) bool {
return snapshot.Failures[i].NodeID < snapshot.Failures[j].NodeID
})
return snapshot
}
func (r *Resolver) nodes(ctx context.Context, service string) ([]registry.Node, error) {
now := r.now()
r.mu.Lock()
if entry, ok := r.cache[service]; ok && entry.expiresAt.After(now) {
nodes := cloneNodes(entry.nodes)
r.mu.Unlock()
return nodes, nil
}
r.mu.Unlock()
nodes, err := r.reg.List(ctx, service)
if err != nil {
return nil, err
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].NodeID < nodes[j].NodeID
})
r.mu.Lock()
r.cache[service] = cacheEntry{
nodes: cloneNodes(nodes),
expiresAt: now.Add(r.cacheTTL),
}
r.mu.Unlock()
return nodes, nil
}
func (r *Resolver) candidates(nodes []registry.Node) []registry.Node {
now := r.now()
r.mu.Lock()
failures := make(map[string]struct{}, len(r.failures))
rejectMaintaining := r.rejectMaintaining
r.pruneFailuresLocked(now)
for nodeID, state := range r.failures {
if state.count >= r.failureThreshold && state.expiresAt.After(now) {
failures[nodeID] = struct{}{}
}
}
r.mu.Unlock()
out := make([]registry.Node, 0, len(nodes))
for _, node := range nodes {
nodeID := strings.TrimSpace(node.NodeID)
if nodeID == "" {
continue
}
if _, failed := failures[nodeID]; failed {
continue
}
if rejectMaintaining && nodeMaintaining(node.Meta) {
continue
}
if !nodeAccepting(node) {
continue
}
out = append(out, node)
}
return out
}
func (r *Resolver) routeCandidates(nodes []registry.Node, routeID, key string) ([]registry.Node, error) {
candidates := r.candidates(nodes)
if len(candidates) == 0 || !nodesDeclareRoutes(candidates) {
return candidates, nil
}
out := make([]registry.Node, 0, len(candidates))
for _, node := range candidates {
route, ok := matchRoute(node.Routes, routeID)
if !ok {
continue
}
if strings.TrimSpace(key) == "" && (route.Stateful || route.Authorized) {
return nil, ErrRouteRequiresKey
}
out = append(out, node)
}
return out, nil
}
func (r *Resolver) selectNode(bucket string, candidates []registry.Node, key string) registry.Node {
if len(candidates) == 1 {
return candidates[0]
}
r.mu.Lock()
strategy := r.strategy
r.mu.Unlock()
switch strategy {
case StrategyRandom:
return candidates[r.randomIndex(len(candidates))]
case StrategyRoundRobin:
return candidates[r.nextCursor(bucket, len(candidates))]
case StrategyWeightedRoundRobin:
return r.selectWeightedRR(bucket, candidates)
default:
return selectWeightedByKey(candidates, key)
}
}
func (r *Resolver) nextCursor(bucket string, size int) int {
if size <= 1 {
return 0
}
r.mu.Lock()
next := r.cursors[bucket]
r.cursors[bucket] = next + 1
r.mu.Unlock()
return int(next % uint64(size)) // #nosec G115 -- 取模结果必定小于 size,size 已经是 int。
}
func (r *Resolver) randomIndex(size int) int {
if size <= 1 {
return 0
}
r.mu.Lock()
if r.rng == nil {
r.rng = rand.New(rand.NewSource(time.Now().UnixNano())) // #nosec G404 -- 非安全用途:服务发现随机负载均衡,不用于凭证或加密。
}
index := r.rng.Intn(size)
r.mu.Unlock()
return index
}
func (r *Resolver) selectWeightedRR(bucket string, candidates []registry.Node) registry.Node {
total := 0
for _, candidate := range candidates {
total += nodeWeight(candidate)
}
if total <= 0 {
return candidates[r.nextCursor(bucket, len(candidates))]
}
index := r.nextCursor(bucket, total)
for _, candidate := range candidates {
weight := nodeWeight(candidate)
if index < weight {
return candidate
}
index -= weight
}
return candidates[len(candidates)-1]
}
func (r *Resolver) pruneFailuresLocked(now time.Time) {
for nodeID, state := range r.failures {
if !state.expiresAt.After(now) {
delete(r.failures, nodeID)
}
}
}
func (r *Resolver) now() time.Time {
if r.clock == nil {
return time.Now()
}
return r.clock()
}
func nodeMaintaining(meta map[string]string) bool {
for _, key := range []string{"maintaining", "is_maintaining", "maintenance"} {
value := strings.TrimSpace(strings.ToLower(meta[key]))
switch value {
case "1", "true", "yes", "y", "on":
return true
}
}
return false
}
func nodeAccepting(node registry.Node) bool {
switch strings.TrimSpace(strings.ToLower(nodeState(node))) {
case "stopping", "stopped", "shut", "hang":
return false
default:
return true
}
}
func nodeState(node registry.Node) string {
if strings.TrimSpace(node.State) != "" {
return strings.TrimSpace(node.State)
}
return strings.TrimSpace(node.Meta["state"])
}
func nodeWeight(node registry.Node) int {
if node.Weight > 0 {
return node.Weight
}
weight, _ := strconv.Atoi(strings.TrimSpace(node.Meta["weight"]))
if weight > 0 {
return weight
}
return 1
}
func nodesDeclareRoutes(nodes []registry.Node) bool {
for _, node := range nodes {
if len(node.Routes) > 0 {
return true
}
}
return false
}
func matchRoute(routes []registry.Route, routeID string) (registry.Route, bool) {
for _, route := range routes {
if strings.TrimSpace(route.ID) == routeID {
return route, true
}
}
return registry.Route{}, false
}
func filterNodesByTags(nodes []registry.Node, tags []string, matchAny bool) []registry.Node {
tags = normalizeTags(tags)
if len(tags) == 0 {
return nodes
}
out := make([]registry.Node, 0, len(nodes))
for _, node := range nodes {
if nodeMatchesTags(node, tags, matchAny) {
out = append(out, node)
}
}
return out
}
func nodeMatchesTags(node registry.Node, tags []string, matchAny bool) bool {
nodeTags := normalizeTags(node.Tags)
if len(nodeTags) == 0 {
return false
}
have := make(map[string]struct{}, len(nodeTags))
for _, tag := range nodeTags {
have[tag] = struct{}{}
}
if matchAny {
for _, tag := range tags {
if _, ok := have[tag]; ok {
return true
}
}
return false
}
for _, tag := range tags {
if _, ok := have[tag]; !ok {
return false
}
}
return true
}
func tagBucket(tags []string, matchAny bool) string {
tags = normalizeTags(tags)
if len(tags) == 0 {
return "all"
}
mode := "all"
if matchAny {
mode = "any"
}
encoded := make([]string, 0, len(tags))
for _, tag := range tags {
encoded = append(encoded, encodeTagBucketPart(tag))
}
return mode + ":" + strings.Join(encoded, ",")
}
func encodeTagBucketPart(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return "_"
}
return base64.RawURLEncoding.EncodeToString([]byte(tag))
}
func normalizeTags(tags []string) []string {
if len(tags) == 0 {
return nil
}
seen := make(map[string]struct{}, len(tags))
out := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(strings.ToLower(tag))
if tag == "" {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
out = append(out, tag)
}
sort.Strings(out)
return out
}
func selectWeightedByKey(candidates []registry.Node, key string) registry.Node {
total := 0
for _, candidate := range candidates {
total += nodeWeight(candidate)
}
if total <= 0 {
return candidates[hashIndex(key, len(candidates))]
}
index := hashIndex(key, total)
for _, candidate := range candidates {
weight := nodeWeight(candidate)
if index < weight {
return candidate
}
index -= weight
}
return candidates[len(candidates)-1]
}
func hashIndex(key string, size int) int {
if size <= 1 {
return 0
}
key = strings.TrimSpace(key)
if key == "" {
return 0
}
h := fnv.New32a()
_, _ = h.Write([]byte(key))
return int(uint64(h.Sum32()) % uint64(size)) // #nosec G115 -- 取模结果必定小于 size,size 已经是 int。
}
func normalizeStrategy(strategy Strategy) Strategy {
value := Strategy(strings.ToLower(strings.TrimSpace(string(strategy))))
switch value {
case StrategyRandom, StrategyRoundRobin, StrategyWeightedRoundRobin:
return value
default:
return StrategyHash
}
}
func cloneNodes(nodes []registry.Node) []registry.Node {
out := append([]registry.Node(nil), nodes...)
for idx := range out {
out[idx].Meta = cloneMeta(out[idx].Meta)
}
return out
}
func cloneMeta(meta map[string]string) map[string]string {
if len(meta) == 0 {
return nil
}
out := make(map[string]string, len(meta))
for key, value := range meta {
out[key] = value
}
return out
}