Files
90CNServer/pvf/services/autosave.go
T
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

604 lines
18 KiB
Go

package services
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
// autosaveMetaVersion guards the metadata layout. A slot written by an
// incompatible version is reported as unusable instead of restored wrongly.
const autosaveMetaVersion = 1
// autosaveVersionWait bounds the wait for the background version session,
// which has to be attached before recovered edits can be recorded against it.
const autosaveVersionWait = 30 * time.Second
// AutosaveStatus is the UI-facing state of the backup slot.
type AutosaveStatus struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
DefaultPath string `json:"defaultPath"`
Exists bool `json:"exists"`
CachedAt int64 `json:"cachedAt"`
SourcePath string `json:"sourcePath"`
SizeBytes int64 `json:"sizeBytes"`
Running bool `json:"running"`
LastError string `json:"lastError"`
}
// RecoveryInfo describes a backup that can be restored on startup.
type RecoveryInfo struct {
SourcePath string `json:"sourcePath"`
SourceName string `json:"sourceName"`
CachedAt int64 `json:"cachedAt"`
SizeBytes int64 `json:"sizeBytes"`
PendingFiles int `json:"pendingFiles"`
SourceExists bool `json:"sourceExists"`
SourceChanged bool `json:"sourceChanged"`
}
// autosaveMeta records the source file and the entries that were pending when
// the backup was written. Removed files are not listed: their paths are gone
// from the archive, so the restore derives them by comparing path sets.
type autosaveMeta struct {
Version int `json:"version"`
SourcePath string `json:"sourcePath"`
CachedAt int64 `json:"cachedAt"`
SourceSize int64 `json:"sourceSize"`
SourceModified int64 `json:"sourceModified"`
ModifiedPaths []string `json:"modifiedPaths"`
AddedPaths []string `json:"addedPaths"`
}
// pendingPaths returns the modified and added entries in a stable order.
func (m autosaveMeta) pendingPaths() []string {
seen := make(map[string]struct{}, len(m.ModifiedPaths)+len(m.AddedPaths))
result := make([]string, 0, len(m.ModifiedPaths)+len(m.AddedPaths))
for _, path := range append(append([]string{}, m.ModifiedPaths...), m.AddedPaths...) {
if path == "" {
continue
}
if _, exists := seen[path]; exists {
continue
}
seen[path] = struct{}{}
result = append(result, path)
}
sort.Strings(result)
return result
}
// AutosaveService keeps a single overwriting snapshot of the current workspace
// so an abnormal exit (crash, killed process) leaves something to recover. The
// snapshot never touches the live archive: it is written from an isolated clone
// and the workspace stays "unsaved" after the write.
type AutosaveService struct {
c *core
settings *SettingsService
running atomic.Bool
mu sync.Mutex
lastErr string
}
// NewAutosaveService creates the backup service and wires it into the shared
// core so save/close handlers can drop a stale slot.
func NewAutosaveService(c *core, settings *SettingsService) *AutosaveService {
service := &AutosaveService{c: c, settings: settings}
if c != nil {
c.attachAutosave(service)
}
return service
}
// Status reports the backup slot without writing anything.
func (s *AutosaveService) Status() AutosaveStatus {
status := AutosaveStatus{Running: s.running.Load()}
if s == nil || s.c == nil {
return status
}
status.DefaultPath, _ = DefaultAutosavePath()
if s.settings != nil {
if settings, err := s.settings.GetSettings(); err == nil {
status.Enabled = settings.AutosaveEnabled
}
}
cachePath, err := s.cachePath()
if err != nil {
status.LastError = err.Error()
return status
}
status.Path = cachePath
if info, err := os.Stat(cachePath); err == nil {
status.Exists = true
status.SizeBytes = info.Size()
}
if meta, ok := readAutosaveMeta(cachePath); ok {
status.CachedAt = meta.CachedAt
status.SourcePath = meta.SourcePath
}
status.LastError = s.lastError()
return status
}
// CreateSnapshot writes the pending edits to the backup slot. The live archive
// is left untouched: the write goes through an isolated clone, so the workspace
// keeps its unsaved state and the source file is never modified.
func (s *AutosaveService) CreateSnapshot() error {
if s == nil || s.c == nil {
return errors.New("定时缓存服务不可用")
}
if !s.running.CompareAndSwap(false, true) {
return errors.New("定时缓存正在写入,请稍后再试")
}
defer s.running.Store(false)
defer requestCacheMaintenance()
cachePath, err := s.cachePath()
if err != nil {
s.recordError(err)
return err
}
s.c.mu.RLock()
a := s.c.archive
if a == nil {
s.c.mu.RUnlock()
s.recordError(ErrNoArchive)
return ErrNoArchive
}
clone := a.CloneForBatch()
sourcePath := a.SourcePath()
edits := a.PendingEdits()
s.c.mu.RUnlock()
if !clone.Modified() {
// Nothing pending (a save can win the race against the timer). Keep the
// existing slot instead of overwriting it with a clean copy.
s.recordError(nil)
return nil
}
if sourcePath == "" {
// An archive without a source file has nowhere to recover into, so a
// backup would only produce a prompt that cannot be fulfilled.
s.recordError(nil)
return nil
}
meta := autosaveMeta{
Version: autosaveMetaVersion,
SourcePath: sourcePath,
CachedAt: time.Now().Unix(),
}
for _, edit := range edits {
path := pvfversion.DisplayPath(edit.Path)
if path == "" {
continue
}
if edit.Kind == pvf.MutationAdded {
meta.AddedPaths = append(meta.AddedPaths, path)
continue
}
meta.ModifiedPaths = append(meta.ModifiedPaths, path)
}
if len(meta.ModifiedPaths)+len(meta.AddedPaths) == 0 {
// Only string-pool state differs (no entry the recovery could replay).
// Writing a slot without restorable paths would just produce an empty
// recovery prompt.
s.recordError(nil)
return nil
}
if info, statErr := os.Stat(sourcePath); statErr == nil {
meta.SourceSize = info.Size()
meta.SourceModified = info.ModTime().Unix()
}
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
s.recordError(err)
return fmt.Errorf("创建备份缓存目录失败: %w", err)
}
// The payload is replaced first and the metadata second: a crash between the
// two renames leaves an older, more conservative path list next to a newer
// payload, which restores a subset of the edits instead of failing.
if err := clone.SaveAs(cachePath); err != nil {
s.recordError(err)
return fmt.Errorf("写入备份缓存失败: %w", err)
}
if err := writeAutosaveMeta(cachePath, meta); err != nil {
s.recordError(err)
return err
}
s.recordError(nil)
return nil
}
// PendingRecovery reports the backup left behind by an interrupted session, or
// nil when the slot is empty.
func (s *AutosaveService) PendingRecovery() (*RecoveryInfo, error) {
if s == nil || s.c == nil {
return nil, nil
}
cachePath, err := s.cachePath()
if err != nil {
return nil, err
}
meta, ok := readAutosaveMeta(cachePath)
if !ok {
return nil, nil
}
payload, err := os.Stat(cachePath)
if err != nil {
// Metadata without a payload is a torn write, not a usable backup.
return nil, nil
}
info := &RecoveryInfo{
SourcePath: meta.SourcePath,
CachedAt: meta.CachedAt,
SizeBytes: payload.Size(),
PendingFiles: len(meta.pendingPaths()),
}
if meta.SourcePath != "" {
info.SourceName = filepath.Base(meta.SourcePath)
if source, err := os.Stat(meta.SourcePath); err == nil {
info.SourceExists = true
info.SourceChanged = meta.SourceModified != 0 &&
(source.ModTime().Unix() != meta.SourceModified || source.Size() != meta.SourceSize)
}
}
return info, nil
}
// Restore reopens the recorded source file and replays the backed-up edits into
// the in-memory overlay, so the workspace ends up dirty exactly like the
// session that was interrupted: modified markers, version changes and the save
// target all stay consistent. The backup itself is kept until the next save or
// an explicit discard.
func (s *AutosaveService) Restore() (ArchiveInfo, error) {
if s == nil || s.c == nil {
return ArchiveInfo{}, errors.New("定时缓存服务不可用")
}
cachePath, err := s.cachePath()
if err != nil {
return ArchiveInfo{}, err
}
meta, ok := readAutosaveMeta(cachePath)
if !ok {
return ArchiveInfo{}, errors.New("没有可恢复的备份缓存")
}
if meta.SourcePath == "" {
return ArchiveInfo{}, errors.New("备份缺少原文件路径,无法恢复")
}
// A clean workspace can be replaced, but unsaved edits of another archive
// must never be dropped by a recovery the user did not aim at them.
s.c.mu.RLock()
dirty := s.c.archive != nil &&
(s.c.archive.ModifiedCount() > 0 || len(s.c.versionArtifactDirty) > 0)
s.c.mu.RUnlock()
if dirty {
return ArchiveInfo{}, errors.New("当前工作区有未保存的修改,请先保存或关闭后再恢复备份")
}
if _, err := os.Stat(meta.SourcePath); err != nil {
return ArchiveInfo{}, fmt.Errorf("原文件不存在,请先找回文件再恢复备份: %s", meta.SourcePath)
}
info, err := s.c.openArchive(meta.SourcePath)
if err != nil {
return ArchiveInfo{}, fmt.Errorf("打开原文件失败: %w", err)
}
if err := s.waitForVersionSession(); err != nil {
return ArchiveInfo{}, err
}
// Paged110 containers keep their page keys next to the original file, so the
// backup borrows the sidecar directory instead of carrying a copy.
backup, err := s.c.openArchiveWithRememberedTextEncoding(cachePath, meta.SourcePath, filepath.Dir(meta.SourcePath))
if err != nil {
return ArchiveInfo{}, fmt.Errorf("打开备份缓存失败: %w", err)
}
a, summary, err := s.replayBackup(meta, backup)
if err != nil {
return ArchiveInfo{}, err
}
s.c.mu.RLock()
info, _ = s.c.archiveInfo()
s.c.mu.RUnlock()
if err := s.c.scheduleArchiveMutations(a, summary); err != nil {
return ArchiveInfo{}, err
}
emitEvent("archive:reloaded", info)
emitVersionState(s.c, "recovered")
return info, nil
}
// Discard deletes the backup slot. It reports whether a cache file was removed.
func (s *AutosaveService) Discard() (bool, error) {
if s == nil || s.c == nil {
return false, errors.New("定时缓存服务不可用")
}
cachePath, err := s.cachePath()
if err != nil {
return false, err
}
return deleteAutosaveFiles(cachePath)
}
// DropForSource deletes the backup only when it belongs to sourcePath. Saving or
// closing another archive must never destroy a backup that still protects a
// different workspace.
func (s *AutosaveService) DropForSource(sourcePath string) {
if s == nil || s.c == nil {
return
}
cachePath, err := s.cachePath()
if err != nil {
return
}
meta, ok := readAutosaveMeta(cachePath)
if !ok {
return
}
if !sameSourcePath(meta.SourcePath, sourcePath) {
return
}
_, _ = deleteAutosaveFiles(cachePath)
}
// DropForWorkspace drops the backup of the currently open workspace. It is the
// exit path's "the user gave up on these edits" cleanup.
func (s *AutosaveService) DropForWorkspace() {
if s == nil || s.c == nil {
return
}
s.c.mu.RLock()
sourcePath := ""
if s.c.archive != nil {
sourcePath = s.c.archive.SourcePath()
}
s.c.mu.RUnlock()
s.DropForSource(sourcePath)
}
// ChooseCachePath asks the user for a backup file location.
func (s *AutosaveService) ChooseCachePath() (string, error) {
if s == nil {
return "", errors.New("定时缓存服务不可用")
}
name := "autosave.pvf"
if current, err := s.cachePath(); err == nil && current != "" {
name = filepath.Base(current)
}
path, err := application.Get().Dialog.SaveFile().
SetFilename(name).
AddFilter("PVF 备份缓存", "*.pvf").
SetMessage("选择定时缓存文件").
PromptForSingleSelection()
if err != nil {
return "", err
}
return path, nil // 空字符串表示用户取消
}
// replayBackup applies the backed-up contents on top of the freshly opened
// source archive. It returns the archive and the mutation summary so the caller
// can refresh the derived indexes.
func (s *AutosaveService) replayBackup(meta autosaveMeta, backup *pvf.Archive) (*pvf.Archive, pvf.MutationSummary, error) {
backupPaths := make(map[string]struct{}, backup.FileCount())
for i := int32(0); i < backup.FileCount(); i++ {
if key := pvfversion.CanonicalPath(backup.Path(i)); key != "" {
backupPaths[key] = struct{}{}
}
}
recovered := make(map[string]struct{})
for _, path := range meta.pendingPaths() {
key := pvfversion.CanonicalPath(path)
if key == "" {
continue
}
// A pending entry the backup does not contain was deleted before the
// snapshot was taken; the path-set comparison below handles it.
if _, exists := backupPaths[key]; exists {
recovered[key] = struct{}{}
}
}
s.c.mu.Lock()
defer s.c.mu.Unlock()
if s.c.archive == nil {
return nil, pvf.MutationSummary{}, ErrNoArchive
}
a := s.c.archive
// Entries the interrupted session deleted are present in the file but
// missing from the backup.
paths := make([]string, 0, len(recovered)+8)
for key := range recovered {
paths = append(paths, key)
}
for i := int32(0); i < a.FileCount(); i++ {
key := pvfversion.CanonicalPath(a.Path(i))
if key == "" {
continue
}
if _, exists := backupPaths[key]; !exists {
paths = append(paths, key)
}
}
if len(paths) == 0 {
return a, pvf.MutationSummary{}, nil
}
sort.Strings(paths)
before, err := pvfversion.ContentSnapshotFromArchive(a, paths)
if err != nil {
return nil, pvf.MutationSummary{}, err
}
desired, err := pvfversion.ContentSnapshotFromArchive(backup, paths)
if err != nil {
return nil, pvf.MutationSummary{}, err
}
// Paths the session never touched keep the file's current content: only the
// recovered and the deleted entries are materialized. The version working
// snapshot is deliberately not consulted, so a recovered payload is never
// skipped on account of another snapshot agreeing with it.
for key := range desired {
if _, exists := recovered[key]; !exists {
delete(desired, key)
}
}
if err := applyContentPathsLocked(s.c, paths, desired, false); err != nil {
return nil, pvf.MutationSummary{}, err
}
after, err := pvfversion.ContentSnapshotFromArchive(a, paths)
if err != nil {
return nil, pvf.MutationSummary{}, err
}
if err := s.c.recordVersionMutationLocked("从定时缓存恢复", before, after); err != nil {
return nil, pvf.MutationSummary{}, err
}
summary := a.MutationsSince(0)
a.ClearMutations()
return a, summary, nil
}
// waitForVersionSession waits until the background version load settles. The
// replayed edits have to be recorded against an attached session; applying them
// earlier would leave the version panel reporting a clean workspace.
func (s *AutosaveService) waitForVersionSession() error {
deadline := time.Now().Add(autosaveVersionWait)
for {
s.c.mu.RLock()
loading := s.c.versionLoading
s.c.mu.RUnlock()
if !loading {
return nil
}
if time.Now().After(deadline) {
return errors.New("版本库仍在后台加载,请稍后重试恢复")
}
time.Sleep(50 * time.Millisecond)
}
}
// cachePath resolves the configured snapshot file, falling back to the
// platform cache directory.
func (s *AutosaveService) cachePath() (string, error) {
if s == nil || s.settings == nil {
return "", errors.New("定时缓存服务不可用")
}
settings, err := s.settings.GetSettings()
if err != nil {
return "", err
}
if settings.AutosavePath != "" {
return settings.AutosavePath, nil
}
return DefaultAutosavePath()
}
func (s *AutosaveService) recordError(err error) {
s.mu.Lock()
defer s.mu.Unlock()
if err == nil {
s.lastErr = ""
return
}
s.lastErr = err.Error()
recordLog("ERROR", "自动缓存", err.Error())
}
func (s *AutosaveService) lastError() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastErr
}
func readAutosaveMeta(cachePath string) (autosaveMeta, bool) {
data, err := os.ReadFile(cachePath + ".json")
if err != nil {
return autosaveMeta{}, false
}
var meta autosaveMeta
if err := json.Unmarshal(data, &meta); err != nil {
return autosaveMeta{}, false
}
if meta.Version != autosaveMetaVersion {
return autosaveMeta{}, false
}
return meta, true
}
func writeAutosaveMeta(cachePath string, meta autosaveMeta) error {
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
metaPath := cachePath + ".json"
temp, err := os.CreateTemp(filepath.Dir(metaPath), ".autosave-*.tmp")
if err != nil {
return fmt.Errorf("创建备份元数据临时文件失败: %w", err)
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(0o600); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
temp.Close()
return fmt.Errorf("写入备份元数据失败: %w", err)
}
if err := temp.Sync(); err != nil {
temp.Close()
return fmt.Errorf("同步备份元数据失败: %w", err)
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, metaPath); err != nil {
return fmt.Errorf("替换备份元数据失败: %w", err)
}
return nil
}
// deleteAutosaveFiles removes the payload and its metadata. Missing files are
// not an error: the caller only cares that no backup is left.
func deleteAutosaveFiles(cachePath string) (bool, error) {
removed := false
if err := os.Remove(cachePath); err == nil {
removed = true
} else if !os.IsNotExist(err) {
return removed, fmt.Errorf("删除备份缓存失败: %w", err)
}
if err := os.Remove(cachePath + ".json"); err == nil {
removed = true
} else if !os.IsNotExist(err) {
return removed, fmt.Errorf("删除备份元数据失败: %w", err)
}
return removed, nil
}
// sameSourcePath compares two archive paths, tolerating separator and case
// differences reported by different platforms.
func sameSourcePath(left, right string) bool {
if left == "" || right == "" {
return left == right
}
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}