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,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
|
||||
}
|
||||
Reference in new issue
Block a user