Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

503 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}