服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
314 lines
9.5 KiB
Go
314 lines
9.5 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"pvfine/internal/buildmode"
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
const logCapacity = 2000
|
|
|
|
type appLogEntry struct {
|
|
ID uint64 `json:"id"`
|
|
Timestamp string `json:"timestamp"`
|
|
Level string `json:"level"`
|
|
Source string `json:"source"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type logBuffer struct {
|
|
mu sync.Mutex
|
|
nextID uint64
|
|
entries []appLogEntry
|
|
emit func(appLogEntry)
|
|
}
|
|
|
|
var applicationLogs logBuffer
|
|
|
|
var finishDisabledDebugPhase = func() {}
|
|
|
|
func developmentLog(format string, args ...any) {
|
|
if buildmode.Development {
|
|
log.Printf(format, args...)
|
|
}
|
|
}
|
|
|
|
func developmentOrErrorLog(err error, format string, args ...any) {
|
|
if buildmode.Development || err != nil {
|
|
log.Printf(format, args...)
|
|
}
|
|
}
|
|
|
|
func debugLog(format string, args ...any) {
|
|
if !buildmode.Development {
|
|
return
|
|
}
|
|
recordLog("DEBUG", "performance", fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func debugPhase(operation, detail string) func() {
|
|
if !buildmode.Development {
|
|
return finishDisabledDebugPhase
|
|
}
|
|
started := time.Now()
|
|
debugLog("%s started %s", operation, detail)
|
|
return func() {
|
|
debugLog("%s finished %s elapsed=%.2fms", operation, detail, elapsedMilliseconds(started))
|
|
}
|
|
}
|
|
|
|
func debugLockAcquired(operation string, started time.Time) {
|
|
if !buildmode.Development {
|
|
return
|
|
}
|
|
debugLog("%s core-lock acquired wait=%.2fms", operation, elapsedMilliseconds(started))
|
|
}
|
|
|
|
var applicationLogStages = struct {
|
|
sync.Mutex
|
|
values map[string]string
|
|
}{values: make(map[string]string)}
|
|
|
|
func (b *logBuffer) append(level, source, message string) appLogEntry {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.nextID++
|
|
entry := appLogEntry{b.nextID, time.Now().Format(time.RFC3339Nano), level, source, message}
|
|
if len(b.entries) == logCapacity {
|
|
copy(b.entries, b.entries[1:])
|
|
b.entries[len(b.entries)-1] = entry
|
|
} else {
|
|
b.entries = append(b.entries, entry)
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func (b *logBuffer) snapshot() []appLogEntry {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]appLogEntry(nil), b.entries...)
|
|
}
|
|
|
|
func recordLog(level, source, message string) {
|
|
applicationLogs.record(level, source, message)
|
|
}
|
|
|
|
func (b *logBuffer) record(level, source, message string) {
|
|
if !buildmode.Development && (level == "DEBUG" || source == "Go" && level != "WARN" && level != "ERROR") {
|
|
return
|
|
}
|
|
entry := b.append(level, source, message)
|
|
b.mu.Lock()
|
|
emit := b.emit
|
|
b.mu.Unlock()
|
|
if emit != nil {
|
|
emit(entry)
|
|
}
|
|
}
|
|
|
|
func (b *logBuffer) setEmitter(emit func(appLogEntry)) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.emit = emit
|
|
}
|
|
|
|
type logCaptureWriter struct{ output io.Writer }
|
|
|
|
// CaptureLogs captures Go/Wails output; production retains warnings and errors.
|
|
func CaptureLogs(output io.Writer) io.Writer {
|
|
return &logCaptureWriter{output: output}
|
|
}
|
|
|
|
func (w *logCaptureWriter) Write(p []byte) (int, error) {
|
|
message := strings.TrimSpace(string(p))
|
|
level := classifyLogLevel(message)
|
|
if !buildmode.Development && level != "WARN" && level != "ERROR" {
|
|
return len(p), nil
|
|
}
|
|
n, err := w.output.Write(p)
|
|
if !buildmode.Development {
|
|
// 保留异常的多行上下文,避免将堆栈的后续行误当成 INFO 丢弃。
|
|
recordLog(level, "Go", message)
|
|
return n, err
|
|
}
|
|
for _, line := range strings.Split(message, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
recordLog(classifyLogLevel(line), "Go", line)
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func classifyLogLevel(message string) string {
|
|
lower := strings.ToLower(message)
|
|
// Timing summaries commonly end in error=<nil>; that is not an error.
|
|
lower = strings.ReplaceAll(lower, "error=<nil>", "")
|
|
for _, token := range []string{"level=error", "[err]", "[error]", "err ", "err:", "error=", "failed", "失败"} {
|
|
if strings.Contains(lower, token) {
|
|
return "ERROR"
|
|
}
|
|
}
|
|
for _, token := range []string{"level=warn", "[warn]", "warning", "警告", "skipped:", "unavailable"} {
|
|
if strings.Contains(lower, token) {
|
|
return "WARN"
|
|
}
|
|
}
|
|
if strings.Contains(lower, "level=debug") || strings.Contains(lower, "[debug]") {
|
|
return "DEBUG"
|
|
}
|
|
return "INFO"
|
|
}
|
|
|
|
// LogServiceError observes errors without changing Wails' error serialization.
|
|
func LogServiceError(err error) []byte {
|
|
if err != nil {
|
|
log.Printf("[ERR] %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ObserveLogEvents makes startup history available after the frontend subscribes.
|
|
func ObserveLogEvents(app *application.App) {
|
|
app.Event.On("app:logs-request", func(*application.CustomEvent) {
|
|
_ = app.Event.Emit("app:logs", applicationLogs.snapshot())
|
|
})
|
|
// application.New logs before its event processor is initialized. Only
|
|
// enable live forwarding after New returns; startup logs stay in history.
|
|
applicationLogs.setEmitter(func(entry appLogEntry) {
|
|
_ = app.Event.Emit("app:log", entry)
|
|
})
|
|
}
|
|
|
|
// Record before emitting: Wails Go event listeners run asynchronously and may
|
|
// observe "ready" before "building" when both events are emitted close together.
|
|
func recordLogEvent(name string, data any) {
|
|
applicationLogStages.Lock()
|
|
defer applicationLogStages.Unlock()
|
|
stages := applicationLogStages.values
|
|
if name == "archive:opened" || name == "archive:closed" {
|
|
for key := range stages {
|
|
if strings.HasPrefix(key, "archive:") {
|
|
delete(stages, key)
|
|
}
|
|
}
|
|
}
|
|
level, message, key, signature := describeLogEvent(name, data)
|
|
if message == "" {
|
|
return
|
|
}
|
|
if key != "" {
|
|
if stages[key] == signature {
|
|
return
|
|
}
|
|
stages[key] = signature
|
|
}
|
|
recordLog(level, name, message)
|
|
}
|
|
|
|
func describeLogEvent(name string, data any) (level, message, key, signature string) {
|
|
level = "INFO"
|
|
switch status := data.(type) {
|
|
case pvf.StringTableIndexEvent:
|
|
if status.Kind == "snapshot" {
|
|
return
|
|
}
|
|
if !buildmode.Development && status.Error == "" {
|
|
return
|
|
}
|
|
kind := "字符串表键值索引"
|
|
if status.Kind == "mapping" {
|
|
kind = "字符串表映射索引"
|
|
}
|
|
message = fmt.Sprintf("%s: 归档=%s; 表=%s; 状态=%s; 条目=%d; 数据=%d B; 本次耗时=%.2f ms; 累计耗时=%.2f ms; 累计表构建=%d; 失败=%d",
|
|
kind, status.ArchivePath, status.Path, status.State, status.Entries, status.Bytes,
|
|
status.DurationMs, status.Stats.BuildDurationMs, status.Stats.TableBuilds, status.Stats.FailedTables)
|
|
if status.Error != "" {
|
|
level = "WARN"
|
|
message += "; 错误=" + status.Error
|
|
}
|
|
case ArchiveInfo:
|
|
action := map[string]string{
|
|
"archive:opened": "归档已打开", "archive:saved": "归档已保存", "archive:reloaded": "归档已重新加载",
|
|
}[name]
|
|
if action == "" {
|
|
return
|
|
}
|
|
size := "未知"
|
|
if stat, err := os.Stat(status.Path); err == nil {
|
|
size = fmt.Sprintf("%d B", stat.Size())
|
|
}
|
|
message = fmt.Sprintf("%s: 路径=%s; 大小=%s; 文件=%d; 分组=%d; 数据体=%d B; 变体=%s; Guard=%t; Paged=%t; 修改=%d",
|
|
action, status.Path, size, status.FileCount, status.GroupCount, status.BodySize, status.Format, status.UsesGuard, status.Paged110, status.ModifiedCount)
|
|
case IndexStatus:
|
|
key = "archive:index"
|
|
signature = fmt.Sprintf("%s/%s/%t/%s/%s", status.State, status.Stage, status.Refreshing, status.Error, status.RefreshError)
|
|
message = fmt.Sprintf("PVF 索引: 状态=%s; 阶段=%s; 数量=%d/%d; 跳过=%d; 缓存命中=%t; 后台刷新=%t; 打开耗时=%.2f ms; 构建耗时=%.2f ms",
|
|
status.State, status.Stage, status.Done, status.Total, status.Skipped, status.CacheHit, status.Refreshing, status.OpenDurationMs, status.BuildDurationMs)
|
|
if status.Error != "" || status.RefreshError != "" {
|
|
level = "ERROR"
|
|
message += "; 错误=" + status.Error + "; 刷新错误=" + status.RefreshError
|
|
}
|
|
case AdvancedSearchIndexStatus:
|
|
key = "archive:advanced-index"
|
|
signature = status.State + "/" + status.Stage + "/" + status.Error
|
|
message = fmt.Sprintf("字符串反向索引: 状态=%s; 阶段=%s; 文件=%d/%d", status.State, status.Stage, status.Done, status.Total)
|
|
if status.Error != "" {
|
|
level = "ERROR"
|
|
message += "; 错误=" + status.Error
|
|
}
|
|
case ImageIndexStatus:
|
|
key = "image:index"
|
|
signature = fmt.Sprintf("%d/%s/%s/%s", status.Generation, status.State, status.Stage, status.Error)
|
|
message = fmt.Sprintf("NPK 图像索引: 目录=%s; 状态=%s; 阶段=%s; 文件=%d/%d; NPK=%d; IMG=%d; 图像=%d; 跳过=%d; 重复=%d; 耗时=%.2f ms",
|
|
status.Directory, status.State, status.Stage, status.Done, status.Total, status.NPKFiles, status.IMGFiles, status.ImageCount, status.Skipped, status.Duplicates, status.BuildDurationMs)
|
|
if status.Error != "" {
|
|
level = "ERROR"
|
|
message += "; 错误=" + status.Error
|
|
}
|
|
case map[string]any:
|
|
if name == "script:log" {
|
|
level = strings.ToUpper(fmt.Sprint(status["level"]))
|
|
if level == "ERR" {
|
|
level = "ERROR"
|
|
}
|
|
message = fmt.Sprint(status["message"])
|
|
} else if name == "script:state" {
|
|
message = fmt.Sprintf("脚本执行: 状态=%v", status["status"])
|
|
if err, ok := status["error"].(string); ok && err != "" {
|
|
level = "ERROR"
|
|
message += "; 错误=" + err
|
|
}
|
|
} else if name == "unpack:done" {
|
|
message = fmt.Sprint(status["message"])
|
|
level = classifyLogLevel(message)
|
|
} else if name == "archive:batch-applied" {
|
|
message = fmt.Sprintf("批处理已应用: %v", status)
|
|
} else if name == "version:changed" {
|
|
if version, ok := status["status"].(VersionStatus); ok && version.Error != "" {
|
|
level = "ERROR"
|
|
message = "版本状态错误: " + version.Error
|
|
}
|
|
}
|
|
}
|
|
if message == "" {
|
|
message = map[string]string{
|
|
"archive:closed": "归档已关闭",
|
|
"archive:file-index-ready": "归档文件目录索引已就绪",
|
|
"archive:script-applied": "脚本修改已应用",
|
|
"version:committed": "版本已提交",
|
|
"version:checked-out": "版本已切换",
|
|
}[name]
|
|
}
|
|
return
|
|
}
|