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

407 lines
13 KiB
Go

package services
import (
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"pvfine/internal/pvf"
)
// searchIndexCacheVersion covers both cache formats: the small JSON snapshot's
// Version field and the SQLite index identity string. Bump it whenever the
// persisted metadata shape changes, so older files are rebuilt instead of
// silently missing a newer field.
const searchIndexCacheVersion = 4
// searchIndexCacheIdentity is the cheap source identity used by the small JSON
// cache. SQLite additionally validates the loaded content digest. The archive
// itself is already read before the index starts, but the
// product-level policy intentionally uses the source file's size and mtime
// rather than hashing the whole 110page container again.
type searchIndexCacheIdentity struct {
ArchivePath string
SourceSize int64
SourceModified int64
FileCount int32
GroupCount int32
BodySize int32
Paged110 bool
TextEncoding string
}
type persistedIndexedMetadata struct {
Name string `json:"name"`
ID string `json:"id"`
Category string `json:"category"`
ListPath string `json:"listPath"`
Path string `json:"path"`
FileIndex int32 `json:"fileIndex"`
Size int32 `json:"size"`
DataType int32 `json:"dataType"`
Rarity int32 `json:"rarity"`
Icon *ImageReference `json:"icon,omitempty"`
FieldImage *ImageReference `json:"fieldImage,omitempty"`
}
type persistedSearchIndex struct {
Version int `json:"version"`
ArchivePath string `json:"archivePath"`
SourceSize int64 `json:"sourceSize"`
SourceModified int64 `json:"sourceModified"`
FileCount int32 `json:"fileCount"`
GroupCount int32 `json:"groupCount"`
BodySize int32 `json:"bodySize"`
Paged110 bool `json:"paged110"`
SpecsFingerprint string `json:"specsFingerprint"`
Total int `json:"total"`
Skipped int `json:"skipped"`
Metadata []persistedIndexedMetadata `json:"metadata"`
}
type searchIndexCacheSnapshot struct {
Identity searchIndexCacheIdentity
SpecsFingerprint string
Total int
Skipped int
Metadata []indexedMetadata
}
func searchIndexSpecFingerprint(specs []searchableListSpec) string {
type entry struct {
ListPath string `json:"listPath"`
Category string `json:"category"`
}
entries := make([]entry, 0, len(specs))
for _, spec := range specs {
entries = append(entries, entry{ListPath: spec.listPath, Category: spec.category})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Category != entries[j].Category {
return entries[i].Category < entries[j].Category
}
return entries[i].ListPath < entries[j].ListPath
})
data, _ := json.Marshal(entries)
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func searchIndexCacheIdentityForArchive(a *pvf.Archive) (searchIndexCacheIdentity, error) {
if a == nil || a.SourcePath() == "" {
return searchIndexCacheIdentity{}, errors.New("归档没有可缓存的干净源文件")
}
path, err := canonicalSearchIndexPath(a.SourcePath())
if err != nil {
return searchIndexCacheIdentity{}, err
}
info, err := os.Stat(path)
if err != nil {
return searchIndexCacheIdentity{}, err
}
archiveInfo := a.Info()
return searchIndexCacheIdentity{
ArchivePath: path,
SourceSize: info.Size(),
SourceModified: info.ModTime().UnixNano(),
FileCount: archiveInfo.FileCount,
GroupCount: archiveInfo.GroupCount,
BodySize: archiveInfo.BodySize,
Paged110: archiveInfo.Paged110,
TextEncoding: archiveInfo.TextEncoding,
}, nil
}
func canonicalSearchIndexPath(value string) (string, error) {
abs, err := filepath.Abs(value)
if err != nil {
return "", err
}
abs = filepath.Clean(abs)
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = filepath.Clean(resolved)
}
return abs, nil
}
func searchIndexCachePath(sourcePath, override string) (string, error) {
if override != "" {
return override, nil
}
canonical, err := canonicalSearchIndexPath(sourcePath)
if err != nil {
return "", err
}
cacheDir, err := os.UserCacheDir()
if err != nil {
return "", err
}
sum := sha256.Sum256([]byte(canonical))
return filepath.Join(cacheDir, "pvfine", "search-index", hex.EncodeToString(sum[:])+".json.gz"), nil
}
func loadSearchIndexCache(a *pvf.Archive, specs []searchableListSpec, override string) (searchIndexCacheSnapshot, error) {
identity, err := searchIndexCacheIdentityForArchive(a)
if err != nil {
return searchIndexCacheSnapshot{}, err
}
path, err := searchIndexCachePath(identity.ArchivePath, override)
if err != nil {
return searchIndexCacheSnapshot{}, err
}
release := holdCachePaths(path, filepath.Dir(path))
defer release()
file, err := os.Open(path)
if err != nil {
return searchIndexCacheSnapshot{}, err
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
return searchIndexCacheSnapshot{}, err
}
var persisted persistedSearchIndex
decodeErr := json.NewDecoder(reader).Decode(&persisted)
closeErr := reader.Close()
if decodeErr != nil {
return searchIndexCacheSnapshot{}, decodeErr
}
if closeErr != nil {
return searchIndexCacheSnapshot{}, closeErr
}
if persisted.Version != searchIndexCacheVersion {
return searchIndexCacheSnapshot{}, errors.New("搜索索引缓存版本不匹配")
}
if persisted.ArchivePath != identity.ArchivePath ||
persisted.SourceSize != identity.SourceSize ||
persisted.SourceModified != identity.SourceModified ||
persisted.FileCount != identity.FileCount ||
persisted.GroupCount != identity.GroupCount ||
persisted.BodySize != identity.BodySize ||
persisted.Paged110 != identity.Paged110 {
return searchIndexCacheSnapshot{}, errors.New("搜索索引缓存已过期")
}
if persisted.SpecsFingerprint != searchIndexSpecFingerprint(specs) {
return searchIndexCacheSnapshot{}, errors.New("搜索索引缓存关系配置不匹配")
}
if persisted.Total < 0 || persisted.Skipped < 0 || len(persisted.Metadata) > persisted.Total {
return searchIndexCacheSnapshot{}, errors.New("搜索索引缓存记录数量无效")
}
metadata := make([]indexedMetadata, 0, len(persisted.Metadata))
for _, saved := range persisted.Metadata {
if saved.Path == "" || saved.Category == "" || saved.ListPath == "" {
return searchIndexCacheSnapshot{}, errors.New("搜索索引缓存包含空记录")
}
fileIndex, ok := a.Find(saved.Path)
if !ok {
return searchIndexCacheSnapshot{}, fmt.Errorf("缓存目标文件不存在: %s", saved.Path)
}
file := a.File(fileIndex)
metadata = append(metadata, indexedMetadata{
name: saved.Name,
id: saved.ID,
category: saved.Category,
listPath: saved.ListPath,
path: a.Path(fileIndex),
fileIndex: fileIndex,
size: file.DataSize,
dataType: file.DataType,
rarity: saved.Rarity,
icon: cloneImageReference(saved.Icon),
fieldImage: cloneImageReference(saved.FieldImage),
})
}
return searchIndexCacheSnapshot{
Identity: identity,
SpecsFingerprint: persisted.SpecsFingerprint,
Total: persisted.Total,
Skipped: persisted.Skipped,
Metadata: metadata,
}, nil
}
func saveSearchIndexCache(snapshot searchIndexCacheSnapshot, override string) error {
path, err := searchIndexCachePath(snapshot.Identity.ArchivePath, override)
if err != nil {
return err
}
release := holdCachePaths(path, filepath.Dir(path))
defer release()
data := persistedSearchIndex{
Version: searchIndexCacheVersion,
ArchivePath: snapshot.Identity.ArchivePath,
SourceSize: snapshot.Identity.SourceSize,
SourceModified: snapshot.Identity.SourceModified,
FileCount: snapshot.Identity.FileCount,
GroupCount: snapshot.Identity.GroupCount,
BodySize: snapshot.Identity.BodySize,
Paged110: snapshot.Identity.Paged110,
SpecsFingerprint: snapshot.SpecsFingerprint,
Total: snapshot.Total,
Skipped: snapshot.Skipped,
Metadata: make([]persistedIndexedMetadata, 0, len(snapshot.Metadata)),
}
for _, value := range snapshot.Metadata {
data.Metadata = append(data.Metadata, persistedIndexedMetadata{
Name: value.name,
ID: value.id,
Category: value.category,
ListPath: value.listPath,
Path: value.path,
FileIndex: value.fileIndex,
Size: value.size,
DataType: value.dataType,
Rarity: value.rarity,
Icon: cloneImageReference(value.icon),
FieldImage: cloneImageReference(value.fieldImage),
})
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), ".search-index-*.tmp")
if err != nil {
return err
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(0o600); err != nil {
_ = temp.Close()
return err
}
writer := gzip.NewWriter(temp)
if err := json.NewEncoder(writer).Encode(data); err != nil {
_ = writer.Close()
_ = temp.Close()
return err
}
if err := writer.Close(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(tempPath, path)
}
// persistSearchIndexCacheAsync snapshots the small canonical metadata slice
// under the core lock and performs compression/file IO off the request path.
// The generation and archive revision checks prevent an older background build
// from overwriting a newer cache after a mutation or archive switch.
func (c *core) persistSearchIndexCacheAsync(a *pvf.Archive, gen uint64, metadata []indexedMetadata, total, skipped int) {
c.mu.RLock()
if c.archive != a || c.indexGen != gen || c.indexStatus.State != IndexStateReady ||
c.indexStatus.Refreshing || a.Modified() {
c.mu.RUnlock()
return
}
revision := c.batchRevision
identity, err := searchIndexCacheIdentityForArchive(a)
if err != nil {
c.mu.RUnlock()
return
}
snapshot := searchIndexCacheSnapshot{
Identity: identity,
SpecsFingerprint: c.searchSpecFingerprint,
Total: total,
Skipped: skipped,
Metadata: cloneIndexedMetadata(metadata),
}
override := c.searchIndexCachePath
c.mu.RUnlock()
finishTask, ok := c.archiveTasks.tryBegin()
if !ok {
return
}
go func() {
defer finishTask()
c.mu.RLock()
valid := c.archive == a && c.indexGen == gen && c.batchRevision == revision &&
c.indexStatus.State == IndexStateReady && !c.indexStatus.Refreshing && !a.Modified()
c.mu.RUnlock()
if !valid {
return
}
_ = saveSearchIndexCache(snapshot, override)
}()
}
func (c *core) persistCurrentSearchIndexCacheAsync() {
c.mu.RLock()
if c.archive == nil || c.diskIndex != nil || c.indexStatus.State != IndexStateReady || c.indexStatus.Refreshing {
c.mu.RUnlock()
return
}
a := c.archive
gen := c.indexGen
metadata := cloneIndexedMetadata(c.searchMetadata)
total := c.indexStatus.Total
skipped := c.indexStatus.Skipped
c.mu.RUnlock()
c.persistSearchIndexCacheAsync(a, gen, metadata, total, skipped)
}
// persistCurrentSQLiteIndexAsync rekeys a completed disk index after Save or
// SaveAs. The archive contents are already reflected in the working database;
// only the source identity changes when the packed PVF is written.
func (c *core) persistCurrentSQLiteIndexAsync() {
c.mu.Lock()
if c.archive == nil || c.diskIndex == nil || !c.diskIndex.ready || c.archive.Modified() {
c.mu.Unlock()
return
}
index := c.diskIndex
a := c.archive
target, identity, err := archiveIndexCachePath(a)
if err != nil || target == index.path {
c.mu.Unlock()
return
}
finishIO := holdCachePaths(filepath.Dir(target))
defer finishIO()
index.dbMu.Lock()
defer index.dbMu.Unlock()
if _, err := index.db.Exec(`UPDATE meta SET value=? WHERE key='identity'`, identity); err != nil {
c.mu.Unlock()
return
}
_, _ = index.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
dir := filepath.Dir(target)
tmp, err := os.CreateTemp(dir, ".index-save-*.db")
if err == nil {
tmpPath := tmp.Name()
_ = tmp.Close()
_ = os.Remove(tmpPath)
if err = copyPrivateSQLiteIndex(index.db, index.path, tmpPath); err == nil {
_ = os.Remove(target)
err = os.Rename(tmpPath, target)
}
if err != nil {
_ = os.Remove(tmpPath)
}
}
if err == nil {
index.identity = identity
if index.releaseCache != nil {
index.releaseCache()
}
index.releaseCache = holdCachePaths(target)
}
c.mu.Unlock()
}