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

+502
View File
@@ -0,0 +1,502 @@
package logcrypt
import (
"bufio"
"crypto/cipher"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// 默认参数。MaxBytes/RetentionDays 可由部署环境覆盖。
const (
// DefaultMaxBytes 是单个日志文件的上限(100 MB)。
DefaultMaxBytes = 100 << 20
// DefaultRetentionDays 是日志保留天数。
DefaultRetentionDays = 7
// DefaultQueueSize 是有界队列长度;队列满时丢行而不是阻塞游戏。
DefaultQueueSize = 8192
// DefaultBufferSize 是 bufio 缓冲。
DefaultBufferSize = 256 * 1024
// DefaultFlushInterval 是强制 flush 间隔(也是掉电最多丢多少日志)。
DefaultFlushInterval = 100 * time.Millisecond
// currentPointerName 指向"当前正在写的文件",滚动时重写。
// 解密工具默认读它,因此外部脚本不必关心轮转命名。
currentPointerName = "current.txt"
)
// ErrClosed 表示写入器已关闭。
var ErrClosed = errors.New("logcrypt: writer is closed")
// Config 是写入器配置。
type Config struct {
// Dir 是日志根目录;每一天在它下面建一个 <YYYYMMDD>/ 子目录。
Dir string
// Prefix 是文件名前缀,例如 "packet"。
Prefix string
// Ext 是文件扩展名,例如 ".log.enc"。
Ext string
// Recipient 是收件人公钥。**必填**:没有它就不写任何日志(绝不退化成明文)。
Recipient *Recipient
// MaxBytes 是单文件上限,<=0 时用 DefaultMaxBytes。
MaxBytes int64
// RetentionDays 是保留天数,<=0 时用 DefaultRetentionDays。
RetentionDays int
// QueueSize / BufferSize / FlushInterval <=0 时用默认值。
QueueSize int
BufferSize int
FlushInterval time.Duration
// DropMarkerKind 是队列溢出时写入的标记事件名。
DropMarkerKind string
// Now 可注入时钟(测试用);nil 时用 time.Now。
Now func() time.Time
// Random 可注入随机源(测试用);nil 时用 crypto/rand。
Random io.Reader
// Logger 可选,用于报告轮转、清理与写入错误。
Logger *slog.Logger
}
// Writer 是"落盘即密文 + 按天分目录 + 按大小轮转 + 过期清理"的异步日志写入器。
//
// 并发模型与既有的 packetLogger 保持一致:
// - Write 只做入队,永不阻塞调用方(游戏线程);队列满则丢弃并计数。
// - 单写 goroutine 独占文件句柄,加密、缓冲、轮转、清理都在它里面完成,
// 因此轮转不需要任何锁。
//
// 每个文件一把随机 DEK;**重启后一定另起新分片**(DEK 不落盘,无法续写旧文件)。
type Writer struct {
cfg Config
header []byte // 当前文件的头(含被公钥封装的 DEK),同时用作记录 AAD
queue chan string
stop chan struct{}
done chan struct{}
dropped atomic.Uint64
pruning atomic.Bool
writeFail atomic.Uint64
mu sync.RWMutex
closed bool
closeErr error
// 以下字段只由 run goroutine 访问。
file *os.File
buf *bufio.Writer
aead cipher.AEAD
path string
day string
index int
bytesInFile int64
recordsInFile int
}
// Open 创建写入器并启动写 goroutine。
//
// 收件人公钥缺失时返回 ErrRecipientMissing——调用方据此关闭该日志通道,
// **绝不写明文**。
func Open(cfg Config) (*Writer, error) {
if strings.TrimSpace(cfg.Dir) == "" {
return nil, errors.New("logcrypt: Dir is required")
}
if cfg.Prefix == "" {
return nil, errors.New("logcrypt: Prefix is required")
}
if cfg.Recipient == nil || cfg.Recipient.pub == nil {
return nil, ErrRecipientMissing
}
if cfg.MaxBytes <= 0 {
cfg.MaxBytes = DefaultMaxBytes
}
if cfg.RetentionDays <= 0 {
cfg.RetentionDays = DefaultRetentionDays
}
if cfg.QueueSize <= 0 {
cfg.QueueSize = DefaultQueueSize
}
if cfg.BufferSize <= 0 {
cfg.BufferSize = DefaultBufferSize
}
if cfg.FlushInterval <= 0 {
cfg.FlushInterval = DefaultFlushInterval
}
if cfg.Now == nil {
cfg.Now = time.Now
}
if cfg.Random == nil {
cfg.Random = defaultRandom()
}
if cfg.DropMarkerKind == "" {
cfg.DropMarkerKind = "log-backpressure-dropped"
}
if err := os.MkdirAll(cfg.Dir, 0o750); err != nil {
return nil, fmt.Errorf("logcrypt: create log dir: %w", err)
}
writer := &Writer{
cfg: cfg,
queue: make(chan string, cfg.QueueSize),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go writer.run()
return writer, nil
}
// Write 入队一行(行尾换行可选)。永不阻塞。
func (w *Writer) Write(line string) {
if w == nil || line == "" {
return
}
if !strings.HasSuffix(line, "\n") {
line += "\n"
}
w.mu.RLock()
defer w.mu.RUnlock()
if w.closed {
return
}
select {
case w.queue <- line:
default:
// 诊断日志绝不能拖住游戏:队列满就丢,由写 goroutine 补一条计数标记。
w.dropped.Add(1)
}
}
// Path 返回当前正在写的文件路径(滚动后变化;Close 之后仍返回最后一个)。
func (w *Writer) Path() string {
if w == nil {
return ""
}
w.mu.RLock()
defer w.mu.RUnlock()
return w.path
}
// KeyID 当前文件使用的收件人公钥标识。
func (w *Writer) KeyID() string {
if w == nil || w.cfg.Recipient == nil {
return ""
}
return w.cfg.Recipient.keyID
}
// Dropped 返回因队列溢出被丢弃的行数。
func (w *Writer) Dropped() uint64 {
if w == nil {
return 0
}
return w.dropped.Load()
}
// Close 停止写入器:排空队列、flush、关闭文件。可重复调用。
func (w *Writer) Close() error {
if w == nil {
return nil
}
w.mu.Lock()
if w.closed {
done := w.done
w.mu.Unlock()
<-done
w.mu.RLock()
defer w.mu.RUnlock()
return w.closeErr
}
w.closed = true
close(w.stop)
done := w.done
w.mu.Unlock()
<-done
w.mu.RLock()
defer w.mu.RUnlock()
return w.closeErr
}
func (w *Writer) now() time.Time { return w.cfg.Now() }
func (w *Writer) warn(msg string, args ...any) {
if w.cfg.Logger != nil {
w.cfg.Logger.Warn(msg, args...)
}
}
func (w *Writer) run() {
defer close(w.done)
ticker := time.NewTicker(w.cfg.FlushInterval)
defer ticker.Stop()
writeDropMarker := func() {
if count := w.dropped.Swap(0); count > 0 {
w.appendLocked(fmt.Sprintf("EVENT kind=%s count=%d\n", w.cfg.DropMarkerKind, count))
}
}
flush := func() {
writeDropMarker()
if w.buf != nil {
_ = w.buf.Flush()
}
}
for {
select {
case line := <-w.queue:
writeDropMarker()
w.appendLocked(line)
case <-ticker.C:
flush()
case <-w.stop:
for {
select {
case line := <-w.queue:
writeDropMarker()
w.appendLocked(line)
default:
flush()
if err := w.closeFileLocked(); err != nil {
w.warn("logcrypt: close failed", "error", err)
w.mu.Lock()
if w.closeErr == nil {
w.closeErr = err
}
w.mu.Unlock()
}
return
}
}
}
}
}
// appendLocked 写一条记录;必要时先轮转。任何失败都只计数并告警,绝不向上抛,
// 以免日志问题影响游戏。
func (w *Writer) appendLocked(line string) {
now := w.now()
day := now.Format("20060102")
if w.file == nil || w.day != day ||
(w.recordsInFile > 0 && w.bytesInFile >= w.cfg.MaxBytes) {
if err := w.rollLocked(now); err != nil {
if n := w.writeFail.Add(1); n == 1 {
w.warn("logcrypt: rotate failed", "dir", w.cfg.Dir, "error", err)
}
return
}
}
record, err := appendRecord(nil, w.aead, w.header, []byte(line), w.cfg.Random)
if err != nil {
if n := w.writeFail.Add(1); n == 1 {
w.warn("logcrypt: encrypt failed", "error", err)
}
return
}
if _, err := w.buf.Write(record); err != nil {
if n := w.writeFail.Add(1); n == 1 {
w.warn("logcrypt: write failed", "path", w.path, "error", err)
}
return
}
w.bytesInFile += int64(len(record))
w.recordsInFile++
}
// rollLocked 关闭当前文件并打开新文件(跨天或超过大小上限)。
//
// 一定新建:DEK 不落盘,续写旧文件无法解密,所以重启/滚动都另起分片。
func (w *Writer) rollLocked(now time.Time) error {
previous := w.path
if err := w.closeFileLocked(); err != nil {
return err
}
day := now.Format("20060102")
path, index, err := w.nextTarget(day)
if err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) //nolint:gosec // G304:路径来自部署配置。
if err != nil {
return err
}
w.file = file
w.buf = bufio.NewWriterSize(file, w.cfg.BufferSize)
w.path = path
w.index = index
w.day = day
w.bytesInFile = 0
w.recordsInFile = 0
w.aead = nil
w.header = nil
dek, ephemeralPublic, wrapNonce, wrappedDEK, err := w.cfg.Recipient.wrapDEK(w.cfg.Random)
if err != nil {
return err
}
header := buildHeader(w.cfg.Recipient, ephemeralPublic, wrapNonce, wrappedDEK)
aead, err := newAEAD(dek)
if err != nil {
return err
}
w.header = header
w.aead = aead
if _, err := w.buf.Write(header); err != nil {
return err
}
w.bytesInFile += int64(len(header))
w.writePointerLocked()
if previous != "" && previous != path {
w.schedulePruneLocked(now)
}
return nil
}
// nextTarget 返回当天应新建的分片路径:取磁盘上最大序号 +1。
func (w *Writer) nextTarget(day string) (string, int, error) {
dir := filepath.Join(w.cfg.Dir, day)
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", 0, fmt.Errorf("logcrypt: create day dir: %w", err)
}
pattern := filepath.Join(dir, fmt.Sprintf("%s-%s-*%s", w.cfg.Prefix, day, w.cfg.Ext))
matches, err := filepath.Glob(pattern)
if err != nil {
return "", 0, fmt.Errorf("logcrypt: glob day dir: %w", err)
}
maxIndex := 0
for _, match := range matches {
base := strings.TrimSuffix(filepath.Base(match), w.cfg.Ext)
parts := strings.Split(base, "-")
index, convErr := strconv.Atoi(parts[len(parts)-1])
if convErr != nil || index <= 0 {
continue
}
if index > maxIndex {
maxIndex = index
}
}
index := maxIndex + 1
name := fmt.Sprintf("%s-%s-%03d%s", w.cfg.Prefix, day, index, w.cfg.Ext)
return filepath.Join(dir, name), index, nil
}
func (w *Writer) closeFileLocked() error {
if w.buf != nil {
if err := w.buf.Flush(); err != nil {
w.warn("logcrypt: flush failed", "path", w.path, "error", err)
}
w.buf = nil
}
if w.file != nil {
err := w.file.Close()
w.file = nil
// 刻意保留 w.path / w.header:Close 之后 Path() 仍应返回"最后写入的文件",
// 调用方与测试都依赖它来定位产物。rollLocked 会在赋值前先取 previous。
return err
}
return nil
}
// writePointerLocked 重写 current.txt,供解密工具/脚本定位"当前文件"。
func (w *Writer) writePointerLocked() {
relative, err := filepath.Rel(w.cfg.Dir, w.path)
if err != nil {
relative = w.path
}
pointer := filepath.Join(w.cfg.Dir, currentPointerName)
_ = os.WriteFile(pointer, []byte(filepath.ToSlash(relative)+"\n"), 0o640)
}
// schedulePruneLocked 异步清理过期目录。只删 <YYYYMMDD> 形状且严格早于截止日的目录,
// 绝不触碰当天目录;同一时刻只允许一次清理在跑。
func (w *Writer) schedulePruneLocked(now time.Time) {
if w.cfg.RetentionDays <= 0 || !w.pruning.CompareAndSwap(false, true) {
return
}
today := now.Format("20060102")
cutoff := now.AddDate(0, 0, -w.cfg.RetentionDays).Format("20060102")
dir := w.cfg.Dir
logger := w.cfg.Logger
go func() {
defer w.pruning.Store(false)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if len(name) != 8 || !isDigits(name) || name == today || name >= cutoff {
continue
}
full := filepath.Join(dir, name)
if removeErr := os.RemoveAll(full); removeErr != nil {
if logger != nil {
logger.Warn("logcrypt: prune failed", "dir", full, "error", removeErr)
}
continue
}
if logger != nil {
logger.Info("logcrypt: pruned expired log dir", "dir", full, "retention_days", w.cfg.RetentionDays)
}
}
}()
}
func isDigits(value string) bool {
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return value != ""
}
// CurrentPath 读取 <dir>/current.txt 指向的当前文件绝对路径。
// 找不到时返回空字符串;供解密工具与外部脚本使用。
func CurrentPath(dir string) string {
raw, err := os.ReadFile(filepath.Join(dir, currentPointerName))
if err != nil {
return ""
}
relative := strings.TrimSpace(string(raw))
if relative == "" {
return ""
}
if filepath.IsAbs(relative) {
return relative
}
return filepath.Join(dir, filepath.FromSlash(relative))
}
// OpenFiles 列出 dir 下所有容器文件(按天目录排序)。
// prefix 为空时匹配全部。
func OpenFiles(dir string, prefix string) ([]string, error) {
var out []string
days, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
pattern := "*"
if prefix != "" {
pattern = prefix + "-*"
}
for _, day := range days {
if !day.IsDir() || len(day.Name()) != 8 || !isDigits(day.Name()) {
continue
}
matches, globErr := filepath.Glob(filepath.Join(dir, day.Name(), pattern))
if globErr != nil {
continue
}
out = append(out, matches...)
}
return out, nil
}