Files
90CNServer/pvf/services/cache.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

321 lines
8.9 KiB
Go

package services
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
)
// CacheService reports and clears the caches the application keeps for itself:
// the semantic search index, the SQLite archive index, the advanced search
// index, the NPK icon index and the timed workspace backup. Everything below
// the cache root is either rebuilt on demand or disposable, so clearing can
// never remove configuration, bookmarks or file sets (those live in the user
// config directory).
type CacheService struct {
maintenanceMu sync.Mutex
cancel context.CancelFunc
done chan struct{}
c *core
settings *SettingsService
// rootOverride is only set by tests.
rootOverride string
}
// NewCacheService creates the cache maintenance service.
func NewCacheService(c *core, settings *SettingsService) *CacheService {
return &CacheService{c: c, settings: settings}
}
func newCacheServiceWithRoot(c *core, settings *SettingsService, root string) *CacheService {
return &CacheService{c: c, settings: settings, rootOverride: root}
}
// CacheUsage is the disk footprint reported to the settings page.
type CacheUsage struct {
LimitBytes int64 `json:"limitBytes"`
IndexBytes int64 `json:"indexBytes"`
BackupBytes int64 `json:"backupBytes"`
ReclaimableBytes int64 `json:"reclaimableBytes"`
OverBudget bool `json:"overBudget"`
Path string `json:"path"`
Files int `json:"files"`
TotalBytes int64 `json:"totalBytes"`
// InUseBytes covers the indexes the running session holds open. They are
// rebuilt once the archive is closed, so a clear pass has to skip them.
InUseBytes int64 `json:"inUseBytes"`
}
// CacheClearResult reports what one clear pass removed.
type CacheClearResult struct {
FreedBytes int64 `json:"freedBytes"`
Usage CacheUsage `json:"usage"`
}
// Usage sums the cache without changing anything.
func (s *CacheService) Usage() (CacheUsage, error) {
root, err := s.cacheRoot()
if err != nil {
return CacheUsage{}, err
}
usage := CacheUsage{Path: root}
inUse := s.inUsePaths()
cacheLeases.Lock()
inUse = append(inUse, leasedCachePathsLocked()...)
cacheLeases.Unlock()
backups := s.autosaveSlotFiles()
if err := walkCacheRoot(root, func(path string, info fs.FileInfo) {
usage.Files++
usage.TotalBytes += info.Size()
if pathInUse(path, backups) {
usage.BackupBytes += info.Size()
}
if pathInUse(path, inUse) {
usage.InUseBytes += info.Size()
}
}); err != nil {
return CacheUsage{}, err
}
// The backup slot can be configured outside the cache root; it still counts
// as application cache.
for _, path := range s.outerAutosaveFiles(root) {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
continue
}
usage.Files++
usage.TotalBytes += info.Size()
usage.BackupBytes += info.Size()
}
usage.IndexBytes = usage.TotalBytes - usage.BackupBytes
if s.settings != nil {
settings, err := s.settings.GetSettings()
if err != nil {
return CacheUsage{}, err
}
usage.LimitBytes = int64(settings.CacheLimitGiB) << 30
}
usage.OverBudget = usage.LimitBytes > 0 && usage.TotalBytes > usage.LimitBytes
protected := append(inUse, backups...)
for _, unit := range collectCacheUnits(root) {
if !unitProtected(unit, protected) {
usage.ReclaimableBytes += unit.size
}
}
return usage, nil
}
// Clear deletes every cache entry it can, leaving indexes that are still in use
// by the running session in place.
func (s *CacheService) Clear() (CacheClearResult, error) {
s.maintenanceMu.Lock()
defer s.maintenanceMu.Unlock()
root, err := s.cacheRoot()
if err != nil {
return CacheClearResult{}, err
}
outerBackups := s.outerAutosaveFiles(root)
inUse := s.inUsePaths()
cacheLeases.Lock()
inUse = append(inUse, leasedCachePathsLocked()...)
freed := int64(0)
if err := walkCacheRoot(root, func(path string, info fs.FileInfo) {
if pathInUse(path, inUse) {
return
}
if err := os.Remove(path); err == nil {
freed += info.Size()
}
}); err != nil {
cacheLeases.Unlock()
return CacheClearResult{}, err
}
pruneEmptyCacheDirs(root, inUse)
for _, path := range outerBackups {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
continue
}
if err := os.Remove(path); err == nil {
freed += info.Size()
}
}
cacheLeases.Unlock()
usage, err := s.Usage()
if err != nil {
return CacheClearResult{}, err
}
return CacheClearResult{FreedBytes: freed, Usage: usage}, nil
}
func (s *CacheService) cacheRoot() (string, error) {
if s == nil {
return "", fmt.Errorf("缓存服务不可用")
}
if s.rootOverride != "" {
return s.rootOverride, nil
}
root, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("获取用户缓存目录失败: %w", err)
}
return filepath.Join(root, "pvfine"), nil
}
// inUsePaths lists the cache files the live session keeps open. SQLite sidecars
// are listed as well: removing a -wal file under an open connection would
// corrupt that database.
func (s *CacheService) inUsePaths() []string {
if s == nil || s.c == nil {
return nil
}
s.c.mu.RLock()
defer s.c.mu.RUnlock()
var paths []string
add := func(path string) {
if path == "" {
return
}
paths = append(paths, path, path+"-wal", path+"-shm", path+"-journal")
}
if s.c.diskIndex != nil {
add(s.c.diskIndex.path)
}
if s.c.advancedDisk != nil {
add(s.c.advancedDisk.dir)
add(s.c.advancedDisk.cachePath)
}
return paths
}
// outerAutosaveFiles returns the backup payload and metadata files that sit
// outside the cache root, so a custom location is reported and cleared too.
func (s *CacheService) outerAutosaveFiles(root string) []string {
var result []string
for _, path := range s.autosaveSlotFiles() {
if !pathWithin(root, path) {
result = append(result, path)
}
}
return result
}
// autosaveSlotFiles returns the backup payload and its metadata file.
func (s *CacheService) autosaveSlotFiles() []string {
if s == nil || s.settings == nil {
return nil
}
settings, err := s.settings.GetSettings()
if err != nil {
return nil
}
path := strings.TrimSpace(settings.AutosavePath)
if path == "" {
if path, err = DefaultAutosavePath(); err != nil {
return nil
}
}
return []string{path, path + ".json"}
}
// webviewCacheEntries are directories under the cache root that belong to the
// embedded webview instead of the application. They hold live webview state
// (localStorage, IndexedDB), so their size is not reported and a clear pass must
// never delete them while the window is running.
var webviewCacheEntries = map[string]struct{}{"WebKit": {}}
// walkCacheRoot calls fn for every regular file below root. A missing root is
// not an error: it just means nothing has been cached yet.
func walkCacheRoot(root string, fn func(path string, info fs.FileInfo)) error {
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
if path == root && os.IsNotExist(err) {
return filepath.SkipAll
}
// A cache entry that cannot be read is not worth failing the whole
// report for.
return nil
}
if entry.IsDir() {
if isWebviewCacheEntry(root, path) {
return filepath.SkipDir
}
return nil
}
info, infoErr := entry.Info()
if infoErr != nil {
return nil
}
fn(path, info)
return nil
})
if err != nil {
return fmt.Errorf("统计缓存占用失败: %w", err)
}
return nil
}
// isWebviewCacheEntry reports whether path is a webview-owned directory directly
// below the cache root.
func isWebviewCacheEntry(root, path string) bool {
relative, err := filepath.Rel(root, path)
if err != nil {
return false
}
_, skip := webviewCacheEntries[relative]
return skip
}
func pathInUse(path string, inUse []string) bool {
for _, candidate := range inUse {
if pathWithin(candidate, path) {
return true
}
}
return false
}
// pathWithin reports whether candidate is root itself or sits inside it.
func pathWithin(root, candidate string) bool {
if root == "" || candidate == "" {
return false
}
cleanedRoot := filepath.Clean(root)
cleanedCandidate := filepath.Clean(candidate)
if cleanedRoot == cleanedCandidate {
return true
}
return strings.HasPrefix(cleanedCandidate, cleanedRoot+string(filepath.Separator))
}
// pruneEmptyCacheDirs removes the directories a clear pass emptied, keeping the
// cache root and anything that still holds files (in-use indexes).
func pruneEmptyCacheDirs(root string, inUse []string) {
directories := make([]string, 0, 8)
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if entry.IsDir() && path != root {
if isWebviewCacheEntry(root, path) {
return filepath.SkipDir
}
directories = append(directories, path)
}
return nil
})
// Deepest first so a parent can become empty by its children leaving.
for i := len(directories) - 1; i >= 0; i-- {
path := directories[i]
if pathInUse(path, inUse) {
continue
}
_ = os.Remove(path) // fails while the directory still has entries
}
}