DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,5 @@
|
||||
// Package replay 提供幂等和加密协议流量使用的 nonce 防重放能力。
|
||||
//
|
||||
// Memory 后端只适合测试或单节点降级。多 gateway / logic 部署必须使用 Redis 等共享后端,
|
||||
// 避免同一个 nonce 被另一个进程再次接受。
|
||||
package replay
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user