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

+5
View File
@@ -0,0 +1,5 @@
// Package replay 提供幂等和加密协议流量使用的 nonce 防重放能力。
//
// Memory 后端只适合测试或单节点降级。多 gateway / logic 部署必须使用 Redis 等共享后端,
// 避免同一个 nonce 被另一个进程再次接受。
package replay
+175
View File
@@ -0,0 +1,175 @@
package replay
import (
"container/heap"
"container/list"
"context"
"sync"
"time"
)
const defaultMemMaxEntries = 1024
type memoryEntry struct {
key string
expiresAt time.Time
generation uint64
}
// MemoryStore 是单进程 LRU + TTL nonce 表。它只适合测试、单节点后台或降级路径;
// 多 gateway / logic 实例必须换成共享 Redis,否则不同进程会各自接受同一 nonce。
type MemoryStore struct {
mu sync.Mutex
maxEntries int
items map[string]*list.Element
order *list.List
expiry expiryHeap
generation uint64
now func() time.Time
}
// NewMemoryStore 创建进程内 nonce 表。maxEntries<=0 时使用保守默认值,避免调用方
// 因配置漏填得到一个永远无法拒重的空表。
func NewMemoryStore(maxEntries int) *MemoryStore {
if maxEntries <= 0 {
maxEntries = defaultMemMaxEntries
}
return &MemoryStore{
maxEntries: maxEntries,
items: make(map[string]*list.Element, maxEntries),
order: list.New(),
expiry: expiryHeap{},
now: time.Now,
}
}
// Seen 记录 nonce 并返回它是否首次出现;重复 nonce 会返回 false。
func (s *MemoryStore) Seen(ctx context.Context, key string, ttl time.Duration) (bool, error) {
_, key, err := normalizeInput(ctx, key, ttl)
if err != nil {
return false, err
}
if s == nil {
return false, ErrReplayStoreUnavailable
}
s.mu.Lock()
defer s.mu.Unlock()
s.ensureLocked()
now := s.now().UTC()
s.pruneExpiredLocked(now)
if existing := s.items[key]; existing != nil {
entry := existing.Value.(memoryEntry)
if entry.expiresAt.After(now) {
s.order.MoveToFront(existing)
return false, nil
}
s.removeLocked(existing)
}
if len(s.items) >= s.maxEntries {
return false, ErrReplayStoreFull
}
s.generation++
entry := memoryEntry{key: key, expiresAt: now.Add(ttl), generation: s.generation}
s.items[key] = s.order.PushFront(entry)
heap.Push(&s.expiry, expiryItem{key: key, expiresAt: entry.expiresAt, generation: entry.generation})
s.compactExpiryLocked()
return true, nil
}
func (s *MemoryStore) ensureLocked() {
if s.maxEntries <= 0 {
s.maxEntries = defaultMemMaxEntries
}
if s.items == nil {
s.items = make(map[string]*list.Element, s.maxEntries)
}
if s.order == nil {
s.order = list.New()
}
if s.expiry == nil {
s.expiry = expiryHeap{}
}
if s.now == nil {
s.now = time.Now
}
}
func (s *MemoryStore) pruneExpiredLocked(now time.Time) {
for s.expiry.Len() > 0 {
next := s.expiry[0]
if next.expiresAt.After(now) {
return
}
heap.Pop(&s.expiry)
element := s.items[next.key]
if element == nil {
continue
}
entry := element.Value.(memoryEntry)
if entry.generation != next.generation || !entry.expiresAt.Equal(next.expiresAt) {
continue
}
s.removeLocked(element)
}
}
func (s *MemoryStore) compactExpiryLocked() {
if s.expiry.Len() <= len(s.items)*2+defaultMemMaxEntries {
return
}
// expiry 是惰性堆:更新会留下旧 generation,直到 prune 时才跳过。
// 从 LRU 链表重建可以防止长进程积累无界的过期记录尾巴。
expiry := make(expiryHeap, 0, len(s.items))
for element := s.order.Front(); element != nil; element = element.Next() {
entry := element.Value.(memoryEntry)
expiry = append(expiry, expiryItem(entry))
}
heap.Init(&expiry)
s.expiry = expiry
}
func (s *MemoryStore) removeLocked(element *list.Element) {
if element == nil {
return
}
entry := element.Value.(memoryEntry)
delete(s.items, entry.key)
s.order.Remove(element)
}
type expiryItem struct {
key string
expiresAt time.Time
generation uint64
}
type expiryHeap []expiryItem
// Len 返回过期堆里的候选记录数量。
func (h expiryHeap) Len() int { return len(h) }
// Less 按过期时间和 generation 排序,保证最早失效的记录先被裁剪。
func (h expiryHeap) Less(i, j int) bool {
if !h[i].expiresAt.Equal(h[j].expiresAt) {
return h[i].expiresAt.Before(h[j].expiresAt)
}
return h[i].generation < h[j].generation
}
// Swap 交换两个堆节点。
func (h expiryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
// Push 把新的过期候选记录加入堆。
func (h *expiryHeap) Push(x any) {
*h = append(*h, x.(expiryItem))
}
// Pop 移除并返回堆尾节点,供 container/heap 维护最小堆。
func (h *expiryHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
+115
View File
@@ -0,0 +1,115 @@
package replay
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"strings"
"time"
"longheng.io/server/internal/platform/db"
)
var (
// ErrReplayRedisExecutorRequired 保留旧 API 兼容;新代码统一用 ErrReplayStoreUnavailable 判断装配缺失。
ErrReplayRedisExecutorRequired = ErrReplayStoreUnavailable
// ErrReplayRedisOperationFailed 通过 errors.Is 暴露 Redis 写入失败,上层应拒绝请求并让调用方重试。
ErrReplayRedisOperationFailed = errors.New("replay redis operation failed")
// ErrReplayRedisUnexpectedReply 通过 errors.Is 暴露驱动返回值异常,避免未知结果被当成放行。
ErrReplayRedisUnexpectedReply = errors.New("replay redis returned unexpected reply")
)
// RedisOptions 是 Redis 重放保护 store 的装配参数。
type RedisOptions struct {
Executor db.RedisExecutor
KeyPrefix string
}
// RedisStore 使用 Redis SET NX EX 实现跨进程 nonce 去重。
type RedisStore struct {
executor db.RedisExecutor
keyPrefix string
}
const redisSeenScript = `
local ok = redis.call("SET", KEYS[1], "1", "NX", "EX", ARGV[1])
if ok then
return 1
end
return 0
`
// NewRedisStore 创建 Redis 重放保护 store;KeyPrefix 为空时使用框架默认前缀。
func NewRedisStore(opts RedisOptions) *RedisStore {
prefix := strings.Trim(strings.TrimSpace(opts.KeyPrefix), ":")
if prefix == "" {
prefix = "longheng:replay"
}
return &RedisStore{executor: opts.Executor, keyPrefix: prefix}
}
// Seen 记录 nonce 并返回它是否首次出现;Redis 不可用时不会放行请求。
func (s *RedisStore) Seen(ctx context.Context, key string, ttl time.Duration) (bool, error) {
ctx, key, err := normalizeInput(ctx, key, ttl)
if err != nil {
return false, err
}
if s == nil || s.executor == nil {
return false, ErrReplayStoreUnavailable
}
reply, err := s.executor.Do(ctx, "EVAL", redisSeenScript, 1, s.redisKey(key), ttlSeconds(ttl))
if err != nil {
return false, wrapRedisError(err)
}
return parseSeenReply(reply)
}
func (s *RedisStore) redisKey(key string) string {
sum := sha256.Sum256([]byte(key))
return s.keyPrefix + ":" + hex.EncodeToString(sum[:])
}
func ttlSeconds(ttl time.Duration) int64 {
seconds := int64(math.Ceil(ttl.Seconds()))
if seconds < 1 {
return 1
}
return seconds
}
func parseSeenReply(reply any) (bool, error) {
switch v := reply.(type) {
case int:
if v == 1 {
return true, nil
}
if v == 0 {
return false, nil
}
case int64:
if v == 1 {
return true, nil
}
if v == 0 {
return false, nil
}
case string:
switch strings.TrimSpace(v) {
case "1":
return true, nil
case "0":
return false, nil
}
case []byte:
switch strings.TrimSpace(string(v)) {
case "1":
return true, nil
case "0":
return false, nil
}
}
return false, fmt.Errorf("%w: %T", ErrReplayRedisUnexpectedReply, reply)
}
+51
View File
@@ -0,0 +1,51 @@
package replay
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
var (
// ErrReplayKeyRequired 通过 errors.Is 暴露空 nonce key。
ErrReplayKeyRequired = errors.New("replay: key is required")
// ErrReplayTTLRequired 通过 errors.Is 暴露非法 replay 保留窗口。
ErrReplayTTLRequired = errors.New("replay: ttl must be positive")
// ErrReplayStoreUnavailable 通过 errors.Is 暴露 nil 或不可用 store。
ErrReplayStoreUnavailable = errors.New("replay: store is unavailable")
// ErrReplayStoreFull 表示不能通过驱逐未过期 nonce 来接收新 nonce。
ErrReplayStoreFull = errors.New("replay: store is full")
)
// Store 记录短生命周期 nonce 是否已经出现过;实现必须保证 check+write 原子化。
type Store interface {
// Seen 在 key 首次出现时返回 (true, nil),重复出现时返回 (false, nil)。
// ttl 是最大保留窗口,应略大于调用方的 MaxSkew。
Seen(ctx context.Context, key string, ttl time.Duration) (firstSeen bool, err error)
}
func normalizeInput(ctx context.Context, key string, ttl time.Duration) (context.Context, string, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, "", err
}
key = strings.TrimSpace(key)
if key == "" {
return nil, "", ErrReplayKeyRequired
}
if ttl <= 0 {
return nil, "", ErrReplayTTLRequired
}
return ctx, key, nil
}
func wrapRedisError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%w: %w", ErrReplayRedisOperationFailed, err)
}